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)); }} 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
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.Convert.ToHexStringLower(.NET 9+) prints the MAC as providers send it;Convert.FromHexStringdecodes the received hex in either case and throwsFormatExceptionfor anything else.CryptographicOperations.FixedTimeEqualscompares the received and the computed MAC without leaking, through timing, how many bytes matched.- The endpoint copies
request.Bodyinto aMemoryStream— the bytes as received — verifies them, and only then deserializes withJsonSerializer. Binding the event to a parameter instead would hand you an object, not the signed bytes. - The run checks GitHub's example (
Hello, World!, secretIt's a Secret to Everybody→sha256=757107ea…3e17), then starts the endpoint and posts real requests with curl. A missingWEBHOOK_SECRETstops the app at start-up instead of computing MACs with an empty key.
Gotchas
FixedTimeEqualsreturnsFalseimmediately 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.FromHexStringaccepts upper-case digits (the run verifiessha256=757107EA…); comparing hex strings with==would reject such a header and is not constant-time anyway.