Verifying a Webhook Signature: The Raw-Body Trap, Verify-Before-Parse, and When a Timestamp Window Is Just Theater
A webhook is an unauthenticated POST from the public internet that you are about to trust enough to flip a subscription, mark an SMS delivered, or kick off a deploy. The signature header is the only thing standing between "my payment provider told me this" and "anyone who found my endpoint told me this." Getting the verification exactly right matters more than almost any other ten lines in the service — and there are three specific ways to get it subtly, silently wrong.
We receive webhooks from three different senders, and each one signs differently. Comparing them side by side is the clearest way to see what is a universal rule and what is per-provider.
The universal rule: hash the raw bytes, not your idea of them
Every signature scheme is an HMAC — a keyed hash — computed over some exact sequence of bytes the sender chose. Your job is to recompute that HMAC with the shared secret and check it matches. The single most common way this breaks:
You read the request, parse the JSON into an object, and then re-serialize that object to a string to hash it.
JSON.parse followed by JSON.stringify does not round-trip byte-for-byte. Key order can change. Whitespace is gone. Unicode escapes get normalized. A trailing newline the sender included disappears. The object is semantically identical and the bytes are different, so your HMAC is different, so every signature fails. It looks exactly like a rotated or wrong secret — you will spend an hour re-checking the key — and it is really that you hashed a reformatted body instead of the one that was signed.
We have a comment in our code pointing at the incident that taught us this, because it took a delivery pipeline down once: hash the RAW body bytes — never a re-serialized JSON — or the digest won't match. The fix is to capture the raw body string (or buffer) before anything parses it, and hash that:
const raw = await request.text(); // the exact bytes, untouched
const expected = hmacSha256Hex(secret, raw);
if (!timingSafeEqual(provided, expected)) return unauthorized();
const body = JSON.parse(raw); // parse ONLY after the signature holds
Note the ordering, which is the second rule.
Verify before you parse
The signature check must run before JSON.parse, not after. Two reasons.
First, correctness: you have to hash the raw bytes anyway (rule one), so you have the raw string in hand before you have an object. Parsing first and hashing the reparse is the raw-body trap all over again.
Second, and more important, security posture: JSON.parse is the first place you execute logic against attacker-controlled input. An unverified body is hostile. If you parse it, branch on its fields, look things up in your database by its ids, and then check the signature, you have already run a pile of code on data you have not authenticated. Verify first; a bad signature should be rejected before a single field is read. In our receivers the order is always: secret present → signature header present → HMAC the raw body → constant-time compare → only now parse and dispatch.
Compare in constant time
When you compare the provided signature to the expected one, a === b is a subtle mistake. String equality short-circuits on the first differing character, so it returns faster for a signature that is wrong in the second byte than one wrong in the fortieth. That timing difference is measurable across many requests and leaks, byte by byte, how much of a forged signature is correct — a classic side channel that lets an attacker eventually construct a valid one.
Use a constant-time comparison: walk the full length regardless of where the first mismatch is, accumulating differences with XOR, and seed the accumulator with the length difference so that a length mismatch is mathematically indistinguishable from a content mismatch at the final check.
function timingSafeEqual(a, b) {
let diff = a.length ^ b.length; // length mismatch folds into the result
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) diff |= (a.charCodeAt(i) ?? 0) ^ (b.charCodeAt(i) ?? 0);
return diff === 0;
}
Replay protection: real only when the timestamp is signed
Here is where the three providers genuinely diverge, and where a lot of copy-pasted "add a 5-minute timestamp window" advice becomes security theater.
A replay attack is someone capturing a valid signed request and sending it again. The defense is usually a timestamp: reject anything older than a few minutes. But that defense is only sound if the timestamp is inside the signed bytes. Otherwise the attacker just edits it.
- Svix-style (our email provider uses it): the signed content is
svix-id + "." + svix-timestamp + "." + rawBody. The timestamp is part of what the HMAC covers, so it is authenticated — an attacker cannot change it without breaking the signature. Here a 5-minute freshness window is real protection, and the uniquesvix-iddoubles as an idempotency key. We enforce both. - Stripe-style: the
stripe-signatureheader carries a signed timestamp and the library'sconstructEventverifies it against the raw body with a tolerance window built in. Same principle — the timestamp is signed — so the window is meaningful. Let the official SDK do it rather than re-implementing the parse. - Raw-body-only (our SMS provider): the signature is
HMAC-SHA256(secret, rawBody)and nothing else. There is no signed timestamp anywhere — not in a header, not in the payload. Adding a timestamp window here would be checking a value the attacker can freely rewrite: pure theater. A signature-nonce cache does not work either, because this provider retries the identical signed body on error, so a nonce cannot tell a legitimate retry from a replay and would drop real retries.
So for the raw-body-only provider we do not pretend to have replay protection we cannot have. Instead we bound the actual (small) replay surface by design: the handler is idempotent and narrowly scoped — it reconciles one specific delivery id, refuses to regress a record that is already in a terminal state, and has no cross-object side effects. Re-applying a captured payload does nothing a legitimate retry would not also do. The honest move is to match your replay defense to what is actually authenticated, not to what a generic tutorial recommends.
Fail loudly enough to see a key rotation
A last detail that saves an outage. When a signature fails, the reflex is to return 401/400 and move on. But a signature failure has two very different causes: a random probe hitting your public endpoint (expected, boring) and your own signing secret drifting out of sync with the provider's after a rotation (a silent, total outage — every real webhook now rejected).
Treat them differently in your observability. An invalid signature should be logged at warn with a queryable event name — not swallowed, and not escalated to an exception/pager for every internet scanner. Then a spike in that warn count is a legible signal: "webhooks started failing verification at 14:03" is a rotation you can catch in minutes, instead of noticing three days later that no subscription has updated.
What Merlonix does
Every inbound webhook receiver — payments, email delivery, SMS delivery status — follows the same spine: require the secret and the signature header, HMAC the raw request body, constant-time compare, and only then parse and dispatch. Replay protection is calibrated per provider to whatever is actually inside the signed bytes: a freshness window and idempotency key where the timestamp is authenticated (Svix, Stripe), and an idempotent, terminal-state-guarded handler where it is not (raw-body-only). Invalid signatures are logged as a queryable warning so a signing-key rotation shows up as a graph, not a mystery. And the raw-body trap in the first section is a comment in our code because it is a mistake we have actually made.
We build Merlonix — uptime, SSL, DNS, and answer-presence monitoring on Cloudflare Workers and Supabase — the same way: verify the exact bytes, name the failure, and never fake a guarantee you cannot make.
→ Related: An HTTP 200 Is Not Uptime: The Monitor Went Green While the Page Served an Error → Related: Running a Monitoring SaaS on Cloudflare Workers + Supabase for Almost Nothing → Related: You Can't Monitor Your Cloudflare App From Cloudflare