Article
Verify Webhook Signatures Without Getting It Wrong
Your webhook endpoint is a public URL that accepts POST requests and does something important — marks an invoice paid, provisions an account, refunds a card. If you don't verify the signature, anyone who learns that URL can forge those events. And if you verify it wrong, you get a false sense of safety while the hole stays open. Signature verification is not hard, but nearly every mistake in it fails silently: the check passes when it shouldn't, and you never find out until money moves.
Why an unverified webhook is dangerous
Two distinct attacks. First, spoofing: an attacker crafts a charge.succeeded payload, POSTs it to your endpoint, and your handler happily fulfills an order that was never paid for. The webhook URL is often discoverable — it shows up in browser network tabs, client-side config, leaked logs, or is simply guessable (/webhooks/stripe). There is no login on a webhook receiver; the signature is the authentication.
Second, replay: even a legitimately-signed event can be captured and re-sent. If your handler is not idempotent and you only check the signature, an attacker who intercepts one valid refund.created can replay it fifty times. The signature is still valid — it was a real event — so a signature check alone will not stop this.
The HMAC-SHA256 model
Almost every provider uses the same primitive: HMAC-SHA256 over the raw request body, keyed by a secret that only you and the provider know.
- The provider computes
HMAC-SHA256(secret, raw_body)and sends the result in a header. - You recompute the same thing on your side and compare.
- If they match, the body was produced by someone holding the secret and was not modified in transit.
HMAC is not encryption and not a plain hash. A plain SHA-256 of the body proves nothing — an attacker can hash their forged body too. The secret is what makes it unforgeable: without it, you cannot produce a matching signature.
How the big providers format the header
The primitive is shared; the packaging differs. Know the shape before you parse it.
| Provider | Header | Format |
|---|---|---|
| Stripe | Stripe-Signature | t=timestamp,v1=hex_hmac (signed payload is timestamp.body) |
| GitHub | X-Hub-Signature-256 | sha256=hex_hmac over the raw body |
| Generic | X-Signature or similar | bare hex or base64 HMAC over the raw body |
Stripe is the important special case: the value it signs is not the body alone but t + "." + body, where t is the timestamp from the same header. If you HMAC the body by itself, your comparison will never match Stripe's v1=. GitHub prefixes its hex digest with the literal sha256=, which you must strip (or include on both sides) before comparing. Generic providers often just send the raw digest — but check whether it is hex or base64, because comparing a hex string to a base64 string always fails.
The three things you must get right
Verify over the RAW body, not the parsed JSON
This is the mistake that quietly breaks everything. Your framework parses req.body into an object, and to re-verify you serialize it back to a string. But JSON.stringify() will not reproduce the exact bytes the provider signed — key order, whitespace, Unicode escaping, and number formatting all differ. The HMAC is over specific bytes; change one and the digest changes completely.
Capture the raw body before any JSON middleware touches it. In Express, use express.raw({ type: 'application/json' }) on the webhook route only. In Next.js route handlers, read await req.text() and parse after you verify.
Use a constant-time comparison
Comparing the two digests with === or == leaks timing information. String equality short-circuits at the first differing byte, so an attacker can measure response times to recover the correct signature one byte at a time. Use a constant-time comparison — crypto.timingSafeEqual in Node — which always inspects every byte.
Check the timestamp to stop replays
Verify that the signed timestamp is recent — within a few minutes of now. This is what defeats replay: a captured-but-old event is rejected even though its signature is valid. Stripe hands you the timestamp in the header; for providers that don't, keep a short-lived store of seen event IDs and reject duplicates.
Common mistakes that silently pass
- Comparing a hex digest against a base64 one, or forgetting to strip the
sha256=prefix — usually caught, but sometimes both sides share the bug and it passes. - Trimming or normalizing the body "to be safe" — any transformation invalidates the HMAC.
- Verifying, then not checking the timestamp — signatures pass, replays sail through.
- Logging the secret or comparing against a secret from the wrong environment (test key against live events).
A compact verification example
import crypto from "node:crypto";
// rawBody: the exact bytes received. header: the provider's signature header.
function verifyGithub(rawBody, header, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
// Length check first — timingSafeEqual throws on unequal lengths.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyStripe(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay guard
const signed = `${t}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
const a = Buffer.from(parts.v1 ?? "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Note both functions take rawBody — a string of the untouched bytes — never a re-serialized object.
Key takeaways
- Treat the webhook URL as public; the signature is the only authentication you have.
- HMAC-SHA256 over the raw body, keyed by the shared secret, is the model behind nearly every provider.
- Verify over the exact raw bytes — never
JSON.stringify()the parsed object back. - Compare digests in constant time (
crypto.timingSafeEqual), and match encodings (hex vs base64). - Check the timestamp and make handlers idempotent to defeat replay.
- Know your provider's header shape: Stripe signs
timestamp.body; GitHub prefixessha256=.
Before you push a receiver to production, run a real payload through it and confirm your logic against a known-good signature. Paste your header, body, and secret into the webhook signature verifier to see exactly what the provider computed versus what your code produces — and catch the raw-body, encoding, and timestamp mistakes before an attacker does.