Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · .NET

How to sign and verify a message with HMAC-SHA256 in C# (.NET)

To sign and verify a message with HMAC-SHA256 in C#, call HMACSHA256.HashData(secret, body) and compare the result with CryptographicOperations.FixedTimeEquals, which runs in constant time. The typical case is a webhook signature header; this recipe checks GitHub's documented example and verifies deliveries in an ASP.NET Core minimal API endpoint.

Tested with .NET 10.0.13 (SDK 10.0.402) (System.Security.Cryptography, ASP.NET Core minimal API) and OpenSSL 3.0.13 (signature of the test request).

Webhook.cs — sign and verify a sha256=<hex> header
using System.Security.Cryptography;
static class Webhook
{
// Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>"
public static string Sign(byte[] secret, byte[] rawBody) =>
"sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData(secret, rawBody));
// Receiver: recompute over the raw body and compare in constant time
public static bool Verify(byte[] secret, byte[] rawBody, string? header)
{
if (header is null || !header.StartsWith("sha256=", StringComparison.Ordinal)) return false;
byte[] received;
try
{
received = Convert.FromHexString(header.AsSpan(7)); // upper- or lower-case hex
}
catch (FormatException)
{
return false;
}
return CryptographicOperations.FixedTimeEquals(received, HMACSHA256.HashData(secret, rawBody));
}
}
Program.cs — a minimal API endpoint that verifies the raw body before deserializing
using System.Text;
using System.Text.Json;
var app = WebApplication.CreateBuilder(args).Build();
byte[] secret = Encoding.UTF8.GetBytes(app.Configuration["WEBHOOK_SECRET"]
?? throw new InvalidOperationException("WEBHOOK_SECRET is not set"));
app.MapPost("/webhook", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // the bytes as received, before any model binding
byte[] rawBody = buffer.ToArray();
if (!Webhook.Verify(secret, rawBody, request.Headers["X-Hub-Signature-256"]))
{
return Results.Text("bad signature\n", statusCode: StatusCodes.Status401Unauthorized);
}
JsonElement e = JsonSerializer.Deserialize<JsonElement>(rawBody);
return Results.Text($"accepted {e.GetProperty("action")} #{e.GetProperty("number")}\n");
});
app.Run();

How it works

  1. HMACSHA256.HashData(key, data) (.NET 6+) is a one-shot static method: no object to create or dispose, and safe to call from concurrent requests.
  2. Convert.ToHexStringLower (.NET 9+) prints the MAC as providers send it; Convert.FromHexString decodes the received hex in either case and throws FormatException for anything else.
  3. CryptographicOperations.FixedTimeEquals compares the received and the computed MAC without leaking, through timing, how many bytes matched.
  4. The endpoint copies request.Body into a MemoryStream — the bytes as received — verifies them, and only then deserializes with JsonSerializer. Binding the event to a parameter instead would hand you an object, not the signed bytes.
  5. The run checks GitHub's example (Hello, World!, secret It's a Secret to Everybody → sha256=757107ea…3e17), then starts the endpoint and posts real requests with curl. A missing WEBHOOK_SECRET stops the app at start-up instead of computing MACs with an empty key.

Gotchas

  • FixedTimeEquals returns False immediately when the lengths differ — the length of a MAC is not secret — so a truncated header is simply rejected, no exception.
  • The MAC covers bytes, not JSON: the same event minified is rejected with 401. Keep model binding away from the body until the signature is verified.
  • Convert.FromHexString accepts upper-case digits (the run verifies sha256=757107EA…); comparing hex strings with == would reject such a header and is not constant-time anyway.

Related recipes