A webhook arrives with a valid HMAC over its raw bytes. Middleware parses the JSON, changes whitespace and key order, then asks the verifier to authenticate the new serialization. The object is equivalent; the signature is not.
Preserve the signed input
import crypto from "node:crypto";
export function verify(raw, header, secret) {
const [ts, sig] = header.split(",");
if (Math.abs(Date.now()/1000-Number(ts)) > 300) return false;
const expected=crypto.createHmac("sha256",secret).update(`${ts}.`).update(raw).digest("hex");
return sig.length===expected.length && crypto.timingSafeEqual(Buffer.from(sig),Buffer.from(expected));
}
Capture raw before JSON parsing. Bind the timestamp into the signature and store an event ID or signature digest in a five-minute replay cache. Reject a second delivery after the first succeeds.
Fixtures
| Fixture | Expected |
|---|---|
| exact raw body | accept once |
| reordered keys | reject |
| one-byte mutation | reject |
| six-minute-old timestamp | reject |
| duplicate valid request | reject as replay |
Create one valid fixture with a fixed timestamp and secret, then derive every negative case from it. Never log the production signature or secret. Record only fixture ID, reason class, and request digest.
prevent: verify raw bytes before business logic
detect: reason-coded rejects and replay-cache hits
recover: rotate secret, retain sanitized evidence, replay known-good events deliberately
This example covers HMAC webhooks, not asymmetric signatures or every provider header format. Follow the provider's canonicalization rules. Which middleware in your stack first destroys access to the original bytes?
Top comments (0)