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
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.
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.
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.
=== 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.
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.
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?
Payments & Financial Systems