Trust Fabric

Four ways webhook signature verification breaks in production

Webhook signature verification is about fifteen lines of code, which is why it gets written once, reviewed quickly, and never looked at again. All four of the mistakes below pass code review. All four work perfectly in staging. Three are exploitable and the fourth quietly double-charges customers.

1. Parsing the body before verifying it

The signature covers the exact bytes that were sent. Body-parsing middleware — express.json() and its equivalents — consumes the stream and hands you an object. If you then re-serialize that object to check the signature, you are hashing a different string than the one that was signed.

Key order changes. Whitespace disappears. Unicode gets normalized. Floats re-render. Any one of those flips the HMAC.

The failure is worse than it looks, because it is intermittent. Payloads with simple ASCII and stable key order verify fine. The one containing a customer name with an umlaut does not. You end up with a webhook endpoint that works for months and then rejects a specific customer's events forever.

// wrong — body is already an object, and JSON.stringify is not
// guaranteed to reproduce the bytes that were signed
app.use(express.json());
verify(JSON.stringify(req.body), sig, secret);

// right — capture the raw bytes first
app.use(express.raw({ type: 'application/json' }));
verify(req.body.toString('utf8'), sig, secret);

2. Comparing signatures with ===

String comparison returns as soon as it finds a differing byte. That makes it fast, and the speed is measurable.

An attacker who can send requests and time the responses learns how many leading bytes of their guess were correct. Guess the first byte — 256 tries — keep the one that took marginally longer, move to the second. A 64-character hex signature falls in roughly 16,000 requests rather than the 1664 a brute-force would need.

In practice, network jitter makes this difficult over the open internet and much easier from inside the same datacenter. "Difficult" is not a security control.

// wrong
if (sig !== expected) return reject();

// right — constant time regardless of where the difference is
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))

One catch: timingSafeEqual throws if the buffers differ in length, which leaks length by exception instead. Compare lengths first, and return the same rejection either way.

3. Accepting signatures with no timestamp window

A correct signature stays correct forever. Nothing about HMAC expires.

So anyone who captures one valid request — a proxy log, an error report with headers attached, a screenshot in a support ticket — can replay it indefinitely. If that event was payment_intent.captured, they can replay a successful payment as many times as your handler will accept it.

This is why the timestamp is inside the signed string rather than beside it. Signing "<timestamp>.<body>" means the timestamp cannot be edited without breaking the signature — so once you verify, you can trust it, and reject anything old.

const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return new Response('stale', { status: 400 });

Five minutes is the conventional window. It has to be wide enough to absorb clock skew between your server and the sender, and narrow enough that a captured request is not useful later.

4. Not deduplicating on the event ID

This one is not a security hole. It is the one that costs money.

Webhook delivery is at-least-once, by design. If your handler takes too long, or the connection drops after you did the work but before the response arrived, the sender cannot tell success from failure — so it retries. Correctly.

If the handler grants a credit, sends an email, or triggers a payout, it does it twice. Neither the sender nor your logs will show anything wrong, because nothing went wrong: you received two well-formed, correctly-signed, legitimate deliveries of the same event.

const id = req.headers.get('Webhook-ID');
if (await seen(id)) return new Response('ok');   // already handled
await markSeen(id);
await handle(event);

Store the ID before doing the work, not after — otherwise a crash mid-handler leaves the event unmarked and it runs again on retry. Better still, make the handler idempotent by construction: granting an entitlement someone already has should be a no-op, not an increment.

The pattern underneath all four

Each of these is the same category of bug: the code is correct about the case you tested and wrong about the case the network produces. Middleware that works until a payload has an umlaut. Comparison that is secure until someone measures it. A signature that is valid until someone saves it. A handler that is right until it runs twice.

None of them fail loudly. That is what makes them worth writing down — a webhook endpoint that has "worked fine for two years" has usually only been asked easy questions.

The verification code PAID publishes, with all four guards in place, is in the webhooks guide. It is short. That is rather the point.


More writing · · PAID integration docs