How to Verify Webhook Signatures (HMAC) the Right Way
Your webhook endpoint is a public URL — anyone on the internet can POST to it. So before you trust a webhook, you must prove two things: it really came from the provider, and it wasn't modified in transit. Providers make this possible by signing every payload with a shared secret using HMAC. Your job is to recompute that signature and compare.
It sounds simple, and the happy path is. But there are three ways to get it subtly wrong that cause hours of pain: the raw-body trap, non-constant-time comparison, and missing replay protection. This guide covers all three, with correct code for the major providers.
How HMAC webhook signatures work
HMAC (Hash-based Message Authentication Code) takes a secret key and a message and produces a fixed-length digest. Anyone with the secret can compute it; anyone without it cannot forge it.
The flow is the same almost everywhere:
- The provider computes
HMAC-SHA256(secret, raw_body)(sometimes overtimestamp + body). - It sends the digest in a header.
- You recompute
HMAC-SHA256(secret, raw_body)and compare to the header — in constant time.
If they match, the request is authentic and untampered. If not, reject it with 401.
Trap #1: verify the RAW body, not parsed JSON
This is the bug behind ~80% of "signature mismatch" reports.
HMAC is computed over exact bytes. If your web framework parses the JSON body and you then re-serialize it to verify, the bytes change — key order, whitespace, unicode escaping all differ — and the digest will never match, even with the correct secret.
❌ Broken (global JSON parser destroys the raw bytes):
app.use(express.json()); // body is now a JS object
app.post("/webhook", (req, res) => {
const body = JSON.stringify(req.body); // re-serialized — DIFFERENT bytes!
// HMAC over this will never match
});
✅ Correct (capture raw bytes on the webhook route):
app.post(
"/webhook",
express.raw({ type: "application/json" }), // req.body is a Buffer of raw bytes
(req, res) => {
const raw = req.body; // verify against THIS
}
);
Trap #2: use a constant-time comparison
Comparing strings with === or == short-circuits at the first differing byte. The tiny timing difference leaks information an attacker can use to forge a signature byte-by-byte. Always use a constant-time comparison.
import crypto from "crypto";
function safeEqual(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
// timingSafeEqual throws if lengths differ — guard first
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
Trap #3: protect against replay
A captured-but-valid request can be replayed by an attacker. Providers that include a timestamp in the signed material (Stripe, Slack) let you reject anything too old:
const FIVE_MIN = 5 * 60 * 1000;
if (Math.abs(Date.now() - timestamp * 1000) > FIVE_MIN) {
return res.status(401).send("timestamp too old");
}
Combine this with idempotency (key off the event ID) so even an in-window duplicate is processed only once.
Copy-paste verification by provider
GitHub — X-Hub-Signature-256
import crypto from "crypto";
function verifyGitHub(rawBody, header, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return (
header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected))
);
}
Stripe — Stripe-Signature (timestamp + body)
Use the official library — it does the timestamped HMAC and replay window for you:
const event = stripe.webhooks.constructEvent(
rawBody, // raw Buffer
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
); // throws on mismatch or stale timestamp
Or by hand, the signed message is ${timestamp}.${rawBody} and you HMAC-SHA256 that.
Shopify — X-Shopify-Hmac-Sha256 (base64)
function verifyShopify(rawBody, header, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("base64"); // note: base64, not hex
return (
header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected))
);
}
Slack — X-Slack-Signature (v0:timestamp:body)
function verifySlack(rawBody, sigHeader, tsHeader, secret) {
const base = `v0:${tsHeader}:${rawBody}`;
const expected =
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
return (
sigHeader.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))
);
}
Debugging a signature mismatch fast
When verification fails, you need to see the exact bytes and headers the provider sent — not what your framework handed you after parsing. That's where a capture tool earns its keep:
npx @anonymilyhq/cli listen 3000
Anonymily shows the raw body and signature header as received, lets you replay the exact request while you fix the verifier, and can re-sign a payload you modified so you can test edge cases:
npx @anonymilyhq/cli replay <hookId> <requestId> --resign
And if you still can't spot the cause, the AI diagnosis compares the received signature against what your secret would produce and tells you precisely which trap you hit — raw body, wrong secret, wrong encoding, or stale timestamp.
Checklist
- ☐ Verify against the raw request body bytes.
- ☐ Use the right encoding (hex vs base64) and header name per provider.
- ☐ Compare in constant time (
crypto.timingSafeEqual). - ☐ Reject stale timestamps where the provider signs them.
- ☐ Make handlers idempotent on event ID.
- ☐ Return
401on mismatch,2xxonly after a successful check.
Next steps
- How to test Stripe webhooks on localhost — signatures in a full Stripe flow.
- How to test webhooks locally — get real signed events to your laptop.
- The Complete Guide to Webhooks — the full picture.
TL;DR
Recompute HMAC-SHA256(secret, raw_body) over the raw bytes, compare in constant time, reject stale timestamps, and use the provider's exact header and encoding. When the math won't line up, capture and replay the real request to see exactly why:
npx @anonymilyhq/cli listen 3000