The setup
A webhook URL is publicly reachable. Anyone on the internet can POST to it. So if a request arrives claiming "Stripe charged $50,000", how does your handler know it actually came from Stripe — not someone trying to mark fake orders as paid?
The answer is HMAC: the provider signs the request body with a shared secret, and your handler verifies the signature using the same secret.
What HMAC is
HMAC ("Hash-based Message Authentication Code") combines a hash function with a secret key in a specific way that's safe against length-extension attacks and other classical traps. The recipe:
HMAC(K, m) = H((K' xor opad) || H((K' xor ipad) || m))
Where:
His a hash function (SHA-256 in 2026)Kis the secret keymis the messageopad,ipadare constants
You don't need to remember the formula — every standard library has hmac. What matters: it's a one-line call.
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex')
What HMAC proves
If you and the provider share a secret SECRET, and the provider computes HMAC(SECRET, body), then anyone who computes the same HMAC must also know SECRET. So a matching signature on an inbound request proves: the body was signed by someone holding the secret.
If you keep the secret secure (env var, secret manager) and the provider keeps it secure on their side, then the signature confirms the request came from them.
It does not prove:
- That the request is fresh (replay attacks possible — see timestamps below).
- That the request is the same one the user expected (CSRF-like risks — match against your application state).
How webhook providers send the signature
Three header layouts in common use:
- Stripe:
Stripe-Signature: t=<unix>,v1=<hex>— HMAC over<t>.<body>. Includes a timestamp. - GitHub:
X-Hub-Signature-256: sha256=<hex>— HMAC over the raw body. - Shopify:
X-Shopify-Hmac-Sha256: <base64>— HMAC over the raw body, base64-encoded.
/tools/webhook-signature-verifier handles the differences for you.
The four classic ways verification fails
In every Stripe / GitHub debugging session I've sat through, the cause is one of:
1. Body mutation
Your framework parsed the JSON before signature verification. The re-serialized body has different whitespace than what the provider signed.
// Wrong — express.json() ate the bytes
app.use(express.json())
app.post('/webhook', (req, res) => {
verify(req.body, ...) // req.body is an object now
})
// Right — raw bytes for the webhook route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
verify(req.body, ...) // req.body is a Buffer
})
2. Header parsing
You treated the whole header as the signature without stripping the prefix.
// Wrong
const sig = req.headers['x-hub-signature-256'] // "sha256=abc..."
// Right
const sig = req.headers['x-hub-signature-256'].slice(7) // "abc..."
3. Encoding mismatch
Comparing hex against base64 (or vice versa). Shopify is the usual culprit because devs assume hex.
4. Timing attack on string compare
a === b short-circuits on the first mismatched character; the time difference is observable. Use crypto.timingSafeEqual() (Node) or the equivalent in your language.
Replay attacks
A signature alone doesn't say when the message was signed. An attacker who recorded a legitimate webhook earlier could replay it. Mitigations:
- Timestamps — Stripe, Slack, others include a timestamp in the signed payload. Reject if it's > 5 minutes old.
- Idempotency — record the event ID; if you've already processed it, skip.
- Both — combine for safety.
Verify it interactively
Stop debugging signature mismatches by reading docs. /tools/webhook-signature-verifier lets you paste body + header + secret and tells you in plain English what's wrong: secret, body whitespace, header parsing, encoding, or stale timestamp. Pure client-side; the secret never leaves your browser.