Verify a webhook signature in ASP.NET Core
Payment providers, GitHub and many SaaS webhooks sign the request body with HMAC-SHA256 and a secret you share with them. To verify: compute the HMAC of the raw body bytes exactly as received (not re-serialized JSON), and compare it with the header in constant time with CryptographicOperations.FixedTimeEquals. The code below checks GitHub's X-Hub-Signature-256 header, and prints True for GitHub's documented example:
using System.Security.Cryptography;
using System.Text;
// In ASP.NET Core, read the raw body before anything parses it:
// app.MapPost("/webhook", async (HttpRequest request) =>
// {
// using var ms = new MemoryStream();
// await request.Body.CopyToAsync(ms);
// string? header = request.Headers["X-Hub-Signature-256"];
// return IsValidSignature(ms.ToArray(), header, secret) ? Results.Ok() : Results.Unauthorized();
// });
Console.WriteLine(IsValidSignature(Encoding.UTF8.GetBytes("Hello, World!"),
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17",
"It's a Secret to Everybody")); // prints True
static bool IsValidSignature(byte[] body, string? header, string secret)
{
const string prefix = "sha256=";
if (header is null || !header.StartsWith(prefix, StringComparison.Ordinal)) return false;
byte[] expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body);
byte[] actual;
try { actual = Convert.FromHexString(header[prefix.Length..]); }
catch (FormatException) { return false; }
// FixedTimeEquals takes the same time wherever the bytes differ, so timing reveals nothing.
return CryptographicOperations.FixedTimeEquals(expected, actual);
}
Provider formats differ: some put a timestamp into the signed text (and a prefix or several signatures into the header), and some hand out the secret Base64-encoded, to be decoded before use. Check their documentation for the exact string that is signed; the tool above lets you try each variation.
HMAC is not a plain hash of key and message
HMAC hashes the key and the message twice with fixed paddings, so SHA256(key + message) gives a different (and weaker) result. Use HMACSHA256.HashData(key, message). Keys longer than the block size (64 bytes for SHA-256, 128 for SHA-512) are hashed first; an empty key is allowed.
FAQ
What is HMAC-SHA256?
A message authentication code: a SHA-256 based signature that only someone with the secret key can produce. It proves the message came from someone with the key and was not changed.
How do I compute HMAC-SHA256 in C#?
HMACSHA256.HashData(keyBytes, messageBytes), then Convert.ToHexStringLower or Convert.ToBase64String, whichever format the other side uses.
Why does my webhook signature not match?
Usually the body: it must be the raw bytes as received, before model binding or JSON parsing, with no re-serialization. Then the key encoding (text or Base64) and the output format (hex or Base64, with or without a prefix).
Why compare with FixedTimeEquals?
A normal comparison stops at the first different byte, and the time it takes can leak how much of a forged signature was right. FixedTimeEquals always takes the same time.