Verifying a webhook signature, and the three ways it goes wrong
An HMAC over the request body proves the sender holds your secret. The verification is four lines — the failures are re-serialising the body, comparing with ===, and forgetting that a valid signature never expires.
Compute an HMAC of the raw request body with your shared secret, and compare it to the header they sent — in constant time. Four lines. The three ways people get it wrong are all subtle, and two of them leave you with a check that passes and protects nothing.
This follows receiving a webhook reliably, which left one hole open: so far, anyone who learns your URL can post to it.
The check
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // the bytes as received
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(String(header).replace(/^sha256=/, ''), 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
The sender has your secret. They hash the body with it and put the result in a header. You do the same and compare. Nobody without the secret can produce a matching hash, so a match means the sender holds it.
That is the whole idea. Now the parts that break it.
Mistake 1: verifying a body that is not the body
This is by far the most common, because the framework does it to you:
app.use(express.json()) // ← parses and discards the raw bytes
app.post('/webhooks/acme', (req, res) => {
verify(JSON.stringify(req.body), ...) // ← a DIFFERENT string
})
JSON.parse then JSON.stringify does not give you back what arrived. Key order can change, whitespace is gone, 4200.0 becomes 4200, unicode escaping may differ. Every one of those changes the hash.
The fix is to keep the raw bytes:
app.post('/webhooks/acme',
express.raw({ type: 'application/json' }), // req.body is a Buffer
(req, res) => {
if (!verify(req.body, req.get('x-acme-signature'), SECRET)) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8')) // parse AFTER verifying
// ...
})
Note the order: verify, then parse. Parsing untrusted input before you know who sent it is doing work on behalf of a stranger.
If you use express.json() globally, capture the raw body in its verify hook and use that — but a dedicated express.raw() on the webhook route is harder to get wrong.
Mistake 2: comparing with ===
return expected === received // wrong
String comparison exits at the first differing byte, so a near-miss takes measurably longer to reject than an immediate miss. Repeat enough times and the timing spells out the expected value.
crypto.timingSafeEqual always reads every byte. It throws when the buffers differ in length, which is why the check above compares lengths first — and why you should not skip that guard and let it throw, since a crash is its own signal.
Whether a timing attack over the internet is realistic against a 32-byte HMAC: honestly, not very. Network jitter drowns the difference. But the correct version is the same length as the wrong one, so there is no reason to keep deciding.
Mistake 3: assuming a valid signature is a fresh one
A signature proves who sent it, not when. Capture one valid request — from a log, a proxy, a crash dump — and it stays valid forever. Send it again next month and it still verifies.
Good providers sign a timestamp alongside the body:
X-Acme-Signature: t=1787994000,v1=9c1f2a...
const [tPart, vPart] = header.split(',')
const timestamp = Number(tPart.split('=')[1])
const signature = vPart.split('=')[1]
// Reject anything too old before spending time on the HMAC.
const age = Math.abs(Date.now() / 1000 - timestamp)
if (!Number.isFinite(timestamp) || age > 300) return false // five minutes
const expected = crypto.createHmac('sha256', secret)
.update(`${timestamp}.`).update(rawBody) // the timestamp is INSIDE the hash
.digest('hex')
The timestamp has to be part of what is signed. A timestamp in an unsigned header is worth nothing — the attacker just changes it.
Five minutes is the usual window: wide enough for clock drift, narrow enough that a captured request is stale by the time anyone finds it. Combined with the event-id idempotency from the previous article, a replay is both too old and already seen.
Check it yourself
Watch the raw-body mistake produce a mismatch, which is the one worth feeling:
// save as sig.mjs
import crypto from 'node:crypto'
const SECRET = 'shh'
const raw = '{"id":"evt_8f2a","amount":4200.0,"note":"héllo"}'
const sign = (s) => crypto.createHmac('sha256', SECRET).update(s).digest('hex')
const sent = sign(raw) // what they signed
const reserialised = sign(JSON.stringify(JSON.parse(raw))) // what you'd verify
console.log('raw bytes ', sent.slice(0, 16))
console.log('re-serialised', reserialised.slice(0, 16))
console.log('match:', sent === reserialised)
node sig.mjs
The two hashes differ, and the payload is not exotic — one trailing .0 and one accented character is enough. In production that mismatch looks like "signature verification is broken", and the instinct is to disable it.
The code
Runnable, and CI keeps it that way: CSTSolution/examples/webhook-receiver — a receiver with signature verification, replay rejection and idempotency.
git clone https://github.com/CSTSolution/examples
cd examples/webhook-receiver
Where this goes next
Together, the two articles are the whole contract: accept fast, store by their event id, verify before you parse, and reject anything stale. That is what Workflow Builder does on every incoming trigger.
Earlier in this series: receiving a webhook without losing events, a job queue in Postgres, and how a background worker authenticates — the same HMAC idea, pointed the other way.