Receiving webhooks safely
A webhook endpoint is a public URL that anyone can call. Verify the signature, reject replays, respond quickly and expect duplicates.
Webhooks are how most SaaS platforms tell you something happened: a payment succeeded, a contact changed, a document was signed. They're convenient, and they're also an unauthenticated public endpoint unless you do something about it.
1. Verify the signature
Most providers sign each request with a shared secret, typically an HMAC-SHA256 of the raw request body, sent in a header. Compute the same hash and compare:
app.MapPost("/webhooks/crm", async (HttpRequest request, IOptions<WebhookOptions> options, WebhookQueue queue) =>
{
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
var header = request.Headers["X-Signature"].ToString(); // e.g. "sha256=9f86d0..."
if (!header.StartsWith("sha256=")) return Results.Unauthorized();
var expected = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(options.Value.Secret),
Encoding.UTF8.GetBytes(body));
byte[] received;
try { received = Convert.FromHexString(header["sha256=".Length..]); }
catch (FormatException) { return Results.Unauthorized(); }
if (!CryptographicOperations.FixedTimeEquals(expected, received))
return Results.Unauthorized();
await queue.EnqueueAsync(body);
return Results.Accepted();
});
Three details matter:
- Hash the raw body, exactly as received. If you let the framework deserialize the JSON first and re-serialize it, the bytes change and the signature never matches.
- Use
CryptographicOperations.FixedTimeEqualsrather than==orSequenceEqual. A normal comparison stops at the first different byte, which can leak information through timing. - Check your provider's exact scheme. Header name, encoding (hex or Base64) and what's included in the hash all vary.
2. Reject old requests
A valid signed request can be captured and sent again. Many providers include a timestamp in the signed content. Reject anything older than a few minutes.
3. Respond fast, process later
Providers wait only a few seconds for a response, then treat the call as failed and retry. If you do the real work inside the request, a slow database call turns into duplicate deliveries. Validate, store or queue the payload, return 2xx, and process it in the background, for example from a Service Bus queue.
4. Expect duplicates and disorder
Providers retry on any failure or timeout, so the same event can arrive more than once, and events can arrive out of order. Store each event's ID and skip ones you've already handled. Where order matters, compare timestamps or version numbers before applying an update, so an old event doesn't overwrite newer data.
Takeaway
Treat a webhook endpoint like any public API: verify the HMAC signature against the raw body with a constant-time comparison, reject stale requests, acknowledge quickly and do the work asynchronously, and handle duplicate and out-of-order events.