Verifying webhook signatures correctly
A payment webhook endpoint is a public URL. Signature verification is what stops it from being a public URL anyone can POST fake "payment succeeded" events to — but the mechanics have more sharp edges than they first appear.
3 min read
Why this can't be skipped
A webhook endpoint has to be reachable from the public internet — that's the entire point, it's how a third-party gateway calls it. Without verifying the request actually came from the gateway, that same public reachability means anyone who discovers the URL could POST a fabricated "transaction succeeded" payload and have a system believe an order was paid for when it wasn't. Signature verification is what closes that gap.
The mechanism: HMAC, and a shared secret
Gateway computes: signature = HMAC-SHA256(secret, some_representation_of_the_payload)
Gateway sends: the payload, plus that signature, in the request
Merchant computes: the same HMAC, using the same secret, over what it
believes is the same representation
Merchant compares: its own computed signature against the one sent
If both sides used the same secret and hashed the same bytes, the signatures match — and since HMAC is a keyed hash, only someone who knows the shared secret (a merchant, and the gateway) could have produced a matching signature. This is symmetric-key authentication, not encryption — the payload itself is often still plain, readable JSON; the signature only proves who sent it, not that it's hidden from anyone reading network traffic.
Sharp edge 1: "the same representation" is not always the raw request body
It's tempting to assume the signature always covers "the JSON body, verbatim." In practice, different gateways — and even different event types from the same gateway — can compute the signature over different things:
Some events: signature travels as a query parameter, computed over a
specific, fixed-order concatenation of individual fields
pulled out of the payload (not the raw body at all)
Other events (from the same gateway): signature travels inside the
JSON body itself, computed over a different, smaller
string built from just a couple of the event's own fields
The only way to get this right is reading that specific gateway's documentation for that specific event type — assuming "it's always the raw body" is a common, and completely reasonable-looking, mistake that produces a verification function that silently never matches.
Sharp edge 2: comparing signatures with === is itself a bug
// Wrong: vulnerable to a timing attack
if (computedSignature === receivedSignature) { ... }
// Right: constant-time comparison
import { timingSafeEqual } from "crypto";
if (computedSignature.length === receivedSignature.length &&
timingSafeEqual(Buffer.from(computedSignature), Buffer.from(receivedSignature))) {
...
}A naive string comparison (===, or most languages' default equality) returns as soon as it finds the first mismatched character — which means the time it takes to fail leaks information about how many leading characters were correct. Given enough attempts, that timing difference is (in principle) enough to reconstruct a valid signature byte by byte. timingSafeEqual-style functions take the same amount of time regardless of where the mismatch is, closing that channel. The length check has to happen before the constant-time comparison, since most timingSafeEqual implementations throw (or behave undefined) on mismatched-length buffers rather than safely returning false.
Sharp edge 3: signature verification has to happen before anything else runs
The verification step belongs as the very first thing an endpoint does — commonly implemented as a guard/middleware that runs ahead of any handler logic — precisely so an invalid signature is rejected before the payload is ever parsed into a domain object, looked up in a database, or acted on in any way.
The practical checklist
- Confirm, per event type, exactly what bytes the signature actually covers (raw body? specific fields, in what order? query param or body field?).
- Use a constant-time comparison function, always checking length first.
- Reject with an error status (not silently ignore) before any other processing happens.
- Never trust an unsigned or incorrectly-signed request, no matter how "obviously legitimate" its payload looks.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. A payment webhook endpoint has no signature verification at all. What's the concrete risk?
2. A developer writes signature verification assuming it always covers the raw JSON body, for every event type from every gateway. What's the risk?
3. Why is comparing two signature strings with a plain === (or default equality) considered a security bug, not just a style nitpick?
4. Where should signature verification happen relative to parsing the webhook payload into a domain object?