Webhooks — HMAC Signature Validation Fails After No Code Change
Fix webhook signature verification failures caused by body parsing order, encoding, and timestamp tolerance, and compare digests in constant time.
Symptoms
- Webhook receivers reject valid payloads: signature mismatch errors while the provider insists it signs correctly
- Failures start after a framework upgrade or middleware addition, with no signature logic touched
- Some events validate and others fail, correlating with payload size or content (unicode, newlines)
Possible causes
- Body-parsing middleware consumed and re-serialized the stream before signature verification read the raw bytes
- The HMAC is computed over the parsed-and-restringified JSON, which differs from the signed original bytes
- Hex versus base64 encoding mismatch between the provider header and the receiver comparison
- Timestamp tolerance rejects legitimate replays within clock skew, or raw body access differs by route
Diagnosis steps
Verify against the provider documented scheme
curl -s https://docs.provider.com/webhooks | grep -A20 -i "signature"Interpretation
The scheme defines: which header, which encoding (hex/base64), which fields join the signed string, and the timestamp format — implement exactly that.
Capture the raw body before parsing
app.use('/webhooks', express.raw({ type: 'application/json' })) # before json parser on this routeInterpretation
If the raw bytes are gone by the time you verify, no algorithm fixes it: capture order is the bug.
Reproduce with a known payload
node -e "const c=require('crypto'); const raw=process.argv[1]; console.log(c.createHmac('sha256',process.env.SECRET).update(raw).digest('hex'))" -- '{"a":1}'Interpretation
A locally computed digest over the exact received bytes, compared to the header, isolates encoding versus ordering versus secret problems.
Root cause
The signature is computed over the exact raw bytes the provider sent. When middleware parses and re-serializes the body first, the verifier hashes different bytes and fails. The same class of bug appears as encoding mismatches (hex compared against base64) or timestamp windows that reject providers within legitimate clock skew.
Fix
Capture the raw body before any parser on the webhook route, verify against the provider documented scheme with exact encoding, include timestamp in the signed string when the scheme requires it with a realistic tolerance window, and compare digests with constant-time equality.
Risk note
Comparing with === leaks digest length and timing, and replaying without timestamp checks lets old webhooks re-execute. Use timingSafeEqual and bounded freshness.
Verify the fix
A replayed signed sample validates, a tampered byte fails, an expired timestamp fails, and parsing middleware order is covered by a regression test.
Symptom walkthrough
The failure pattern indicts infrastructure rather than cryptography: no code changed, but a framework upgrade or a new body-parsing middleware broke every webhook. The mechanism:
provider signs: {"a":1,"b":2} (exact bytes)
middleware does: req.body -> JSON.stringify -> {'b':2,'a':1}
verifier hashes: {"b":2,"a":1} (different bytes)
HMAC mismatch — always
The encoding twin is equally mechanical: comparing a hex digest against a base64 header fails for every event. And the size correlation (small events pass, large ones fail) points at parser limits truncating the stream before verification.
Fix in practice
Route-ordered raw capture, then exact-scheme verification:
const crypto = require('node:crypto');
// 1. raw bytes BEFORE any JSON parser, on this route only:
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
// 2. exact scheme: hex, over the raw body
const expected = crypto
.createHmac('sha256', process.env.STRIPE_SECRET)
.update(req.body) // Buffer: the exact bytes
.digest('hex');
const provided = req.headers['stripe-signature'];
// 3. constant-time compare, with a length pre-check
const a = Buffer.from(expected);
const b = Buffer.from(String(provided));
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!ok) return res.status(400).end();
handle(JSON.parse(req.body.toString()));
});
For timestamp-bearing schemes (Stripe t=…,v1=…), parse the parts, check t within a window of a few minutes to absorb clock skew, then verify the digest over timestamp + body exactly as documented.