How to know a webhook really came from the provider
A webhook endpoint that accepts anything posted to it is an open door. Signature verification closes it, and almost every implementation of it has the same two bugs.
By Enrique M. · 8 min read · Last reviewed 2026-10-18
The shape of the scheme
The provider signs the request body with a secret only you and it know, and sends the signature in a header. You recompute the signature over the body you received and compare.
The signature is usually an HMAC, and HMAC is the right primitive here: it authenticates the message and detects any modification in transit, with a key. A plain hash of the body would be useless, because anybody who can see the body can recompute the hash.
// Provider side
signature = HMAC_SHA256(secret, raw_body)
// Your side
expected = HMAC_SHA256(secret, raw_body_received)
valid = constant_time_equals(signature_received, expected) The raw body, not the parsed one
This is the first bug, and it is the one that breaks implementations that work perfectly in testing. A signature is computed over the exact bytes of the body. If your framework parsed the JSON and then you re-serialise it to compute the signature, the bytes will differ.
Whitespace, key order, the escaping of a slash, the encoding of a non-ASCII character: all of it changes the bytes. A payload with `"name": "Ada"` and one with `"name":"Ada"` sign differently, and the second one will be rejected with a valid signature on the other side.
Most frameworks give you the raw body somewhere. Use that, always.
- Express: `express.raw({ type: "application/json" })` on the webhook route, and read `req.body` as a Buffer.
- Fastify: `bodyLimit` plus a content type parser that keeps the raw buffer.
- Django: `request.body` rather than `request.POST`.
- Rails: `request.raw_post`.
The timestamp, and why replay protection is not optional
A valid signature proves the request came from the provider. It does not prove the request is new, and a signature captured from a log or a proxy can be replayed indefinitely.
The fix is to include a timestamp in the signed payload, and to reject anything outside a window — five minutes is the usual choice. GitHub, Stripe and Slack all send the timestamp in a header and all document this window.
t=1767225600,v1=5257a869e7ec... // GitHub style
// Reject when |now - t| > 300 seconds
if (Math.abs(now - t) > 300) return 400; Compare in constant time
The second bug. A string comparison in most languages returns as soon as it finds a difference, which makes the time it takes depend on how many leading characters matched.
It is a timing side channel. In practice the attack is hard, but the fix is one function call and there is no reason to leave it. Compare the raw bytes with a constant-time comparison, and do it over the decoded digest rather than over the hex string.
import { timingSafeEqual } from "node:crypto";
const a = Buffer.from(expectedHex, "hex");
const b = Buffer.from(receivedHex, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b); Do the work after the response
Providers retry a webhook that does not get a success response, and they enforce a timeout, often five or ten seconds. If your handler does a slow database write before responding, you will get duplicate deliveries, which is why every provider sends an idempotency identifier.
The pattern is to verify the signature, enqueue the event, respond with a 2xx, and do the real work in a worker. A duplicate delivery then does no harm, because the consumer checks the identifier it has already processed.
What to take away
- Sign the raw bytes, never a re-serialised version of the parsed body.
- Include a timestamp in the signed payload and reject anything outside a five-minute window.
- Compare digests in constant time; the fix is one function call.
- Respond quickly and process asynchronously, because providers retry and they will time you out.