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
| Method | What it does |
|---|---|
ToHmacSha256 | HMAC-SHA256 of the text with a secret, as lower-case hex |
VerifyHmacSha256 | Checks a webhook signature in constant time (hex, Base64 or sha256= prefix) |
ToSha256 | SHA-256 hash of the text, as upper-case hex |
ToMd5 | MD5 hash of the text, as upper-case hex (checksums and cache keys only) |
GetBytes | Text to bytes (UTF-8 or a given Encoding) |
ToStream | Text to a read-only MemoryStream at position 0 |
ToHex | Bytes to lower-case hex |
FromHex | Hex text (any case) to bytes |
ToBase64 / FromBase64 | Text to standard Base64 and back |
ToBase64Url | Text or bytes to URL-safe Base64 without padding |
FromBase64Url | Base64url text (with or without padding) to bytes |
IsValidEmail | Checks that the text is a well-formed e-mail address |
IsValidUrl | Checks that the text is an absolute http or https URL with no hidden characters |
IsValidIban | Checks 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.
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:
string mac = "what do ya want for nothing?".ToHmacSha256("Jefe");
// → "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
- A
nulltext orsecretthrowsArgumentNullException(parameterpayloadorsecret). - An empty text is allowed and still returns 64 hex characters.
==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.
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:
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
nulltext orsecretthrowsArgumentNullException. - A
null, empty, white-space or malformed signature returnsfalse. So do a signature of the wrong length (for example a truncated hex value) and a prefix other thansha256=.
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.
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.
string fingerprint = "invoice-2024-0042.pdf".ToSha256();
// → "FF0A20DFA9EF492EF24D5748370F8307DF6D401357DB41FD85488CA7BD391ADA"
string known = "abc".ToSha256();
// → "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"
string empty = string.Empty.ToSha256();
// → "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
- A
nulltext throwsArgumentNullException(parameterstr). - Do not use a plain SHA-256 hash to store passwords. Use a password hashing algorithm such as PBKDF2, bcrypt or Argon2.
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.
string cacheKey = "user:1042:avatar".ToMd5();
// → "3FD6F6B950C3C3AF80AAAA9435C48899"
string known = "abc".ToMd5();
// → "900150983CD24FB0D6963F7D28E17F72"
string empty = string.Empty.ToMd5();
// → "D41D8CD98F00B204E9800998ECF8427E"
- A
nulltext throwsArgumentNullException(parameterstr).
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.
byte[] utf8 = "é".GetBytes();
// → [0xC3, 0xA9]
byte[] utf16 = "A".GetBytes(Encoding.Unicode);
// → [0x41, 0x00]
- A
nulltext throwsArgumentNullException(parameterstr). Anullencoding throwsArgumentNullException(parameterencoding). Encodinglives inSystem.Text, so the overload with an encoding needsusing 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.
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);
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 (
Lengthis 0). - A
nulltext throwsArgumentNullException(parametertext). Anullencoding throwsArgumentNullException(parameterencoding).
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.
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:
string hash = SHA256.HashData("abc".GetBytes()).ToHex();
// → "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
- A
nullarray throwsArgumentNullException(parameterbytes). ToHexalways uses lower case.FromHexreads 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.
byte[] lower = "ff00".FromHex();
// → [0xFF, 0x00]
byte[] mixed = "aBcD".FromHex();
// → [0xAB, 0xCD]
byte[] empty = "".FromHex();
// → []
- A
nulltext throwsArgumentNullException(parameterhex). - An odd length (
"abc"), a non-hex character ("0g","zz") or a separator ("ff 00") throwsFormatException.
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.
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
nulltext throwsArgumentNullException(parametertextforToBase64,base64forFromBase64). Anullencoding throwsArgumentNullException(parameterencoding). FromBase64throwsFormatExceptionwhen the text is not valid Base64, for example"not base64!".- Decode with the same encoding you used to encode.
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.
string standard = "???".ToBase64();
// → "Pz8/"
string urlSafe = "???".ToBase64Url();
// → "Pz8_"
string greeting = "hello".ToBase64Url();
// → "aGVsbG8"
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
nulltext throwsArgumentNullException(parametertext). Anullarray throwsArgumentNullException(parameterbytes). - 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.
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:
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
nulltext throwsArgumentNullException(parameterbase64Url). - There is no overload that returns a string. Decode the bytes with
Encoding.UTF8.GetStringwhen the content is text.
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.
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@localhostanduser@example.care 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.
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):
bool spoofed = "http://example.com/exe.txt".IsValidUrl(); // → false
bool zeroWidth = "http://example.com/ab".IsValidUrl(); // → false
- Percent-encoded characters such as
%20and%0D%0Aare 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.
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.
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
IsValidIbanchecks only the general 15 to 34 range and the checksum.
See also
- JSON and safe logging:
IsValidJson,FromJson<T>and masking sensitive data in logs. - String predicates:
IsBase64and other checks on strings. - String parsing: turning text into typed values without exceptions.
- Conventions: the null, exception and culture rules every method follows.