Skip to main content

Encoding, Hashing and Validation

These methods turn text into bytes and back, hash it, sign it, and check that user input has the right shape. Use them at the edges of your system: webhook endpoints, tokens in links, checksums, cache keys and form validation. All of them use UTF-8 unless you pass an Encoding.

At a glance​

MethodWhat it does
ToHmacSha256HMAC-SHA256 of the text with a secret, as lower-case hex
VerifyHmacSha256Checks a webhook signature in constant time (hex, Base64 or sha256= prefix)
ToSha256SHA-256 hash of the text, as upper-case hex
ToMd5MD5 hash of the text, as upper-case hex (checksums and cache keys only)
GetBytesText to bytes (UTF-8 or a given Encoding)
ToStreamText to a read-only MemoryStream at position 0
ToHexBytes to lower-case hex
FromHexHex text (any case) to bytes
ToBase64 / FromBase64Text to standard Base64 and back
ToBase64UrlText or bytes to URL-safe Base64 without padding
FromBase64UrlBase64url text (with or without padding) to bytes
IsValidEmailChecks that the text is a well-formed e-mail address
IsValidUrlChecks that the text is an absolute http or https URL with no hidden characters
IsValidIbanChecks an IBAN with the ISO 13616 mod-97 checksum

All the methods are in the System namespace, so you do not need a using directive for them.

Webhook signatures​

Payment providers, Git hosts and many SaaS products sign each webhook. They compute an HMAC-SHA256 of the raw request body with a secret that only you and they know, and send the result in a header. You compute the same value and compare.

ToHmacSha256​

ToHmacSha256(secret) computes the HMAC-SHA256 of the text with secret and returns 64 lower-case hex characters. Both the text and the secret are encoded as UTF-8. Use it when you send webhooks and need to sign them.

Sign an outgoing webhook
string body = "{\"orderId\":1042,\"status\":\"paid\"}";
string secret = "whsec_orders_2024";

string signature = body.ToHmacSha256(secret);
// → "3ba25134aaf254b193ad2428fad478e72d5651f0c1f630c95e6f0be4171c2c58"

request.Headers.Add("X-Signature-256", $"sha256={signature}");

The result matches the published test vectors. This is RFC 4231 test case 2:

RFC 4231 test vector
string mac = "what do ya want for nothing?".ToHmacSha256("Jefe");
// → "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
  • A null text or secret throws ArgumentNullException (parameter payload or secret).
  • An empty text is allowed and still returns 64 hex characters.
warning
Do not compare signatures with ==

Comparing the result of ToHmacSha256 with the received header using == or string.Equals leaks timing information. Use VerifyHmacSha256 to check incoming signatures.

VerifyHmacSha256​

VerifyHmacSha256(secret, signature) computes the HMAC-SHA256 of the text and compares it with signature in constant time, so an attacker cannot guess the signature byte by byte from response times. It returns true only when the signature matches.

The signature can be in any of the formats providers use:

  • hex, in lower or upper case;
  • Base64 of the 32 signature bytes;
  • either of the above with a sha256= prefix (GitHub style; the prefix is case-insensitive).

Leading and trailing white space around the signature is ignored.

Formats VerifyHmacSha256 accepts
string body = "{\"orderId\":1042,\"status\":\"paid\"}";
string secret = "whsec_orders_2024";

bool hex = body.VerifyHmacSha256(secret, "3ba25134aaf254b193ad2428fad478e72d5651f0c1f630c95e6f0be4171c2c58");
// → true

bool prefixedUpperCase = body.VerifyHmacSha256(
secret, "sha256=3BA25134AAF254B193AD2428FAD478E72D5651F0C1F630C95E6F0BE4171C2C58");
// → true

bool base64 = body.VerifyHmacSha256(secret, "O6JRNKryVLGTrSQo+tR45y1WUfDB9jDJXm8L5BccLFg=");
// → true

bool otherAlgorithm = body.VerifyHmacSha256(
secret, "sha1=3ba25134aaf254b193ad2428fad478e72d5651f0c1f630c95e6f0be4171c2c58");
// → false

bool wrongSecret = body.VerifyHmacSha256(
"whsec_other", "3ba25134aaf254b193ad2428fad478e72d5651f0c1f630c95e6f0be4171c2c58");
// → false

bool missing = body.VerifyHmacSha256(secret, null);
// → false

A complete webhook endpoint in ASP.NET Core reads the raw body, verifies it, and only then parses it:

Program.cs: verify a payment webhook
app.MapPost("/webhooks/payments", async (HttpRequest request, IConfiguration configuration) =>
{
string secret = configuration["Payments:WebhookSecret"]
?? throw new InvalidOperationException("Payments:WebhookSecret is not configured.");

using StreamReader reader = new StreamReader(request.Body);
string body = await reader.ReadToEndAsync();
string? signature = request.Headers["X-Signature-256"];

if (!body.VerifyHmacSha256(secret, signature))
{
return Results.Unauthorized();
}

PaymentEvent? paymentEvent = body.FromJson<PaymentEvent>();
// Handle the event...
return Results.Ok();
});
  • A null text or secret throws ArgumentNullException.
  • A null, empty, white-space or malformed signature returns false. So do a signature of the wrong length (for example a truncated hex value) and a prefix other than sha256=.
Verify the raw body

Sign and verify the body exactly as it was received. If you deserialize the JSON and serialize it again, the white space and property order can change, and the signature no longer matches.

Providers that sign more than the body

Some providers sign a composed string, such as a timestamp and the body joined with a ., and put several values in one header. Parse that header yourself, build the signed string the way the provider documents, and pass it as the text. The secret is always used as its UTF-8 bytes. If your provider gives you a Base64-encoded key that must be decoded first, these methods do not fit.

FromJson<T> is described on the JSON and safe logging page.

Hashing​

ToSha256​

ToSha256() computes the SHA-256 hash of the UTF-8 bytes of the text and returns 64 upper-case hex characters. Use it for content fingerprints, ETags and deduplication keys.

Fingerprint a document name
string fingerprint = "invoice-2024-0042.pdf".ToSha256();
// → "FF0A20DFA9EF492EF24D5748370F8307DF6D401357DB41FD85488CA7BD391ADA"

string known = "abc".ToSha256();
// → "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"

string empty = string.Empty.ToSha256();
// → "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
  • A null text throws ArgumentNullException (parameter str).
  • Do not use a plain SHA-256 hash to store passwords. Use a password hashing algorithm such as PBKDF2, bcrypt or Argon2.
Upper case here, lower case elsewhere

ToSha256 and ToMd5 return upper-case hex. ToHmacSha256 and ToHex return lower-case hex. If you compare a hash with a value from another system, compare with StringComparison.OrdinalIgnoreCase, or produce lower-case hex from the bytes with ToHex.

ToMd5​

ToMd5() computes the MD5 hash of the UTF-8 bytes of the text and returns 32 upper-case hex characters. Use it only where a short, non-secret checksum is enough: cache keys, change detection, or matching a legacy system that already uses MD5.

Build a cache key
string cacheKey = "user:1042:avatar".ToMd5();
// → "3FD6F6B950C3C3AF80AAAA9435C48899"

string known = "abc".ToMd5();
// → "900150983CD24FB0D6963F7D28E17F72"

string empty = string.Empty.ToMd5();
// → "D41D8CD98F00B204E9800998ECF8427E"
  • A null text throws ArgumentNullException (parameter str).
warning

MD5 is broken for security use. Do not use it for passwords, signatures or anything an attacker could tamper with. Use ToHmacSha256 for signatures.

Text, bytes and streams​

GetBytes​

GetBytes() encodes the text as UTF-8 bytes. GetBytes(encoding) uses the Encoding you pass. Use it before hashing, encrypting or writing bytes yourself.

Encode text as bytes
byte[] utf8 = "é".GetBytes();
// → [0xC3, 0xA9]

byte[] utf16 = "A".GetBytes(Encoding.Unicode);
// → [0x41, 0x00]
  • A null text throws ArgumentNullException (parameter str). A null encoding throws ArgumentNullException (parameter encoding).
  • Encoding lives in System.Text, so the overload with an encoding needs using System.Text;.

ToStream​

ToStream() puts the UTF-8 bytes of the text in a read-only MemoryStream positioned at 0. ToStream(encoding) uses the Encoding you pass. Use it to feed an API that takes a Stream: an HTTP upload, a parser, or a unit test of code that reads a stream.

Send JSON as a stream
string invoiceJson = "{\"invoiceId\":\"INV-2024-0042\",\"total\":120.50}";

using Stream body = invoiceJson.ToStream();
using StreamContent content = new StreamContent(body);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

HttpResponseMessage response = await httpClient.PostAsync("api/v1/invoices", content);
Stream properties
using Stream stream = "héllo".ToStream();

long position = stream.Position; // → 0
long length = stream.Length; // → 6
bool canRead = stream.CanRead; // → true
bool canWrite = stream.CanWrite; // → false
  • The length is 6 because é takes two bytes in UTF-8.
  • The caller owns the stream. Dispose it with using.
  • An empty text gives an empty stream (Length is 0).
  • A null text throws ArgumentNullException (parameter text). A null encoding throws ArgumentNullException (parameter encoding).

Hex​

ToHex​

ToHex() encodes a byte[] as lower-case hex, two characters per byte. Use it to show hashes, keys and binary IDs as text.

Bytes to hex
string packed = new byte[] { 255, 0 }.ToHex();
// → "ff00"

string mixed = new byte[] { 0x01, 0xAB, 0x7F }.ToHex();
// → "01ab7f"

string none = new byte[0].ToHex();
// → ""

Together with GetBytes it gives you a lower-case SHA-256 hash. SHA256.HashData is in System.Security.Cryptography on .NET 5 and later:

Lower-case SHA-256 hash
string hash = SHA256.HashData("abc".GetBytes()).ToHex();
// → "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
  • A null array throws ArgumentNullException (parameter bytes).
  • ToHex always uses lower case. FromHex reads it back.

FromHex​

FromHex() decodes hex text into bytes. It accepts upper, lower and mixed case, with no separators. It is the reverse of ToHex.

Hex to bytes
byte[] lower = "ff00".FromHex();
// → [0xFF, 0x00]

byte[] mixed = "aBcD".FromHex();
// → [0xAB, 0xCD]

byte[] empty = "".FromHex();
// → []
  • A null text throws ArgumentNullException (parameter hex).
  • An odd length ("abc"), a non-hex character ("0g", "zz") or a separator ("ff 00") throws FormatException.

Base64​

ToBase64 and FromBase64​

ToBase64() encodes the UTF-8 bytes of the text as standard Base64. FromBase64() decodes standard Base64 back into UTF-8 text. Both have an overload that takes an Encoding. Use them for Basic authentication headers, data URIs and storing text in systems that accept only ASCII.

Base64 round trip
string encoded = "Order #1042 confirmed".ToBase64();
// → "T3JkZXIgIzEwNDIgY29uZmlybWVk"

string accented = "héllo".ToBase64();
// → "aMOpbGxv"

string decoded = "SGVsbG8=".FromBase64();
// → "Hello"

string utf16 = "Hi".ToBase64(Encoding.Unicode).FromBase64(Encoding.Unicode);
// → "Hi"
  • A null text throws ArgumentNullException (parameter text for ToBase64, base64 for FromBase64). A null encoding throws ArgumentNullException (parameter encoding).
  • FromBase64 throws FormatException when the text is not valid Base64, for example "not base64!".
  • Decode with the same encoding you used to encode.
tip

To check input before you decode it, use IsBase64.

ToBase64Url​

ToBase64Url() encodes as Base64url (RFC 4648 §5): - instead of +, _ instead of /, and no = padding. The result is safe in URLs, query strings and file names without escaping. It works on a string (its UTF-8 bytes) and on a byte[]. Use it for tokens in links, JWT parts and short IDs.

Base64url versus Base64
string standard = "???".ToBase64();
// → "Pz8/"

string urlSafe = "???".ToBase64Url();
// → "Pz8_"

string greeting = "hello".ToBase64Url();
// → "aGVsbG8"
A short invitation token from a Guid
Guid invitationId = new Guid("3f2504e0-4f89-11d3-9a0c-0305e82c3301");

string token = invitationId.ToByteArray().ToBase64Url();
// → "4AQlP4lP0xGaDAMF6CwzAQ"

string link = $"https://app.example.com/invitations/{token}";
// → "https://app.example.com/invitations/4AQlP4lP0xGaDAMF6CwzAQ"
  • A null text throws ArgumentNullException (parameter text). A null array throws ArgumentNullException (parameter bytes).
  • An empty text or array returns "".
  • The result never contains +, / or =.

FromBase64Url​

FromBase64Url() decodes Base64url text into bytes. Padding is optional: "aGVsbG8" and "aGVsbG8=" give the same bytes. It is the reverse of ToBase64Url.

Read the invitation token back
string token = "4AQlP4lP0xGaDAMF6CwzAQ";

Guid invitationId = new Guid(token.FromBase64Url());
// → 3f2504e0-4f89-11d3-9a0c-0305e82c3301

byte[] urlSafe = "-_8".FromBase64Url();
// → [0xFB, 0xFF]

string text = Encoding.UTF8.GetString("aGVsbG8".FromBase64Url());
// → "hello"

The decoder is strict. It accepts only A-Z, a-z, 0-9, - and _, followed by at most two = characters, and only where the last block is incomplete. Each line below throws FormatException:

Input that FromBase64Url rejects
byte[] standardAlphabet = "+/8=".FromBase64Url(); // standard Base64 characters
byte[] whiteSpace = "QQ ".FromBase64Url(); // white space
byte[] tooMuchPadding = "Zg===".FromBase64Url(); // more than two padding characters
byte[] onlyPadding = "==".FromBase64Url(); // padding with no data
byte[] needlessPadding = "Zm9v=".FromBase64Url(); // padding after a complete block
byte[] badLength = "a".FromBase64Url(); // invalid length
  • A null text throws ArgumentNullException (parameter base64Url).
  • There is no overload that returns a string. Decode the bytes with Encoding.UTF8.GetString when the content is text.
note

Because the decoder rejects white space and standard Base64, a token that was copied with a trailing line break, or encoded with ToBase64 by mistake, fails loudly instead of decoding to the wrong bytes. Catch FormatException at the boundary and return a 400 or 404 response.

Validation​

The validators return false for null and never throw. They check the format only: IsValidEmail does not check that the mailbox exists, and IsValidUrl does not check that the host resolves. IsValidEmail and IsValidUrl do not trim the input, so trim user input first if you want to accept surrounding white space. IsValidIban ignores spaces anywhere, but not tabs or line breaks.

To check JSON text, use IsValidJson on the JSON and safe logging page.

IsValidEmail​

IsValidEmail() checks that the text looks like a deliverable address of the form name@domain.tld. It is a practical subset of RFC 5322: ASCII only, no spaces, no display name, at most 64 characters before the @ and 254 in all.

Validate a sign-up e-mail
string? missingEmail = null;

bool plain = "user@example.com".IsValidEmail(); // → true
bool tagged = "user+tag@example.com".IsValidEmail(); // → true
bool mixedCase = "John.Doe@Example.COM".IsValidEmail(); // → true
bool subdomain = "user@mail.sub.example.co.uk".IsValidEmail(); // → true

bool noTld = "user@localhost".IsValidEmail(); // → false
bool displayName = "John <john@example.com>".IsValidEmail(); // → false
bool doubleDot = "first..last@example.com".IsValidEmail(); // → false
bool leadingSpace = " user@example.com".IsValidEmail(); // → false
bool injected = "user@example.com\nBcc: victim@example.com".IsValidEmail(); // → false
bool missing = missingEmail.IsValidEmail(); // → false
  • The domain needs at least one dot and a top-level domain of two or more letters, so user@localhost and user@example.c are invalid.
  • A dot cannot start or end the part before the @, and two dots cannot follow each other.
  • Any leading or trailing white space, including a final line break, makes the address invalid. This blocks header injection when the address goes into an e-mail header.
  • Addresses with non-ASCII characters are rejected.

IsValidUrl​

IsValidUrl() checks that the text is an absolute http or https URL with a host. It also rejects white space, control characters and invisible format characters anywhere in the text, so the raw text is safe to reuse after it passes.

Validate a callback URL
bool https = "https://example.com:8443/path?q=1&r=2".IsValidUrl(); // → true
bool local = "http://localhost:5000".IsValidUrl(); // → true
bool idn = "http://bücher.de/".IsValidUrl(); // → true
bool encoded = "http://example.com/a%20b?q=a%0D%0A".IsValidUrl(); // → true

bool ftp = "ftp://example.com/file".IsValidUrl(); // → false
bool script = "javascript:alert(1)".IsValidUrl(); // → false
bool relative = "/relative/path".IsValidUrl(); // → false
bool noScheme = "example.com".IsValidUrl(); // → false
bool padded = " http://example.com ".IsValidUrl(); // → false
bool injected = "http://example.com/\r\nSet-Cookie: x=1".IsValidUrl(); // → false
bool spaceInPath = "http://example.com/a b".IsValidUrl(); // → false

Hidden characters that make a URL look different from what it is are rejected, for example U+202E (right-to-left override) and U+200B (zero-width space):

Hidden characters
bool spoofed = "http://example.com/‮exe.txt".IsValidUrl(); // → false
bool zeroWidth = "http://example.com/a​b".IsValidUrl(); // → false
  • Percent-encoded characters such as %20 and %0D%0A are fine. Only the raw characters are rejected.
  • Internationalized domain names (bücher.de, 例え.jp) and their punycode form (xn--bcher-kva.de) are accepted.
  • The zero-width joiner and non-joiner (U+200D, U+200C) are allowed only where real text needs them: between two non-ASCII characters, as in emoji sequences and Persian or Indic words. They are never allowed in the user information before @, and in the host they must also meet the IDNA2008 context rules. Tag characters are allowed only inside an emoji subdivision flag, and never in the host.
  • An unpaired surrogate (for example a string cut in the middle of an emoji) returns false.
Not an SSRF guard

IsValidUrl checks the format only. It accepts localhost and any IP address. If your server fetches URLs that users supply, also resolve the host and block private and loopback addresses.

IsValidIban​

IsValidIban() checks an international bank account number: two letters, two check digits, then the account part, 15 to 34 characters in all, with a valid ISO 13616 mod-97 checksum. Spaces are ignored and case does not matter.

Validate a payee IBAN
string? missingIban = null;

bool grouped = "JO94 CBJO 0010 0000 0000 0131 0003 02".IsValidIban(); // → true
bool compact = "GB82WEST12345698765432".IsValidIban(); // → true
bool lowerCase = "jo94cbjo0010000000000131000302".IsValidIban(); // → true
bool german = "DE89370400440532013000".IsValidIban(); // → true

bool badChecksum = "GB82WEST12345698765433".IsValidIban(); // → false
bool badCheckDigits = "GB83WEST12345698765432".IsValidIban(); // → false
bool hyphens = "GB82-WEST-1234-5698-7654-32".IsValidIban(); // → false
bool tooShort = "GB82WEST1234".IsValidIban(); // → false
bool missing = missingIban.IsValidIban(); // → false
  • Only the space character is removed. Hyphens, tabs and other separators make the IBAN invalid.
  • Every other character must be an ASCII letter or digit. Arabic-Indic digits, full-width letters and other non-ASCII letters or digits are rejected.
  • Country-specific lengths are not checked. A Jordanian IBAN must be 30 characters, but IsValidIban checks only the general 15 to 34 range and the checksum.

See also​