Verify Webhook Payloads: HMAC, Timestamps, and Replay Protection
Build an Express receiver that rejects forged, tampered, and replayed webhooks with constant-time HMAC checks.
What you'll build / learn
A Node.js webhook receiver with Express that rejects forged payloads (HMAC-SHA256 over the raw body), tampered payloads (constant-time comparison), and replayed deliveries (a signed timestamp window plus a nonce cache) — the same scheme Stripe and GitHub use, implemented from scratch so you understand every check.
Prerequisites
- Node.js 18+ (Express 5's minimum). Verified on Node 20.19; the current LTS line is 24.x. No other runtime dependencies — signing uses the built-in
node:cryptomodule. - Express 5.2.1 (latest as of this writing) — installed below.
- Any OS with a shell; commands shown for macOS/Linux. On Windows, use
setinstead ofexport.
1. Scaffold the project
mkdir webhook-receiver && cd webhook-receiver
npm init -y
npm pkg set type=module
npm install express@5
Generate a signing secret and export it in every terminal you use:
export WEBHOOK_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
echo $WEBHOOK_SECRET # save this — sender and receiver must share it
2. Understand the scheme before writing code
The sender computes HMAC-SHA256(secret, "<timestamp>.<raw body>") and ships it in a header:
X-Webhook-Signature: t=1724227200,v1=5257a869e7ec...
Three properties fall out of this design:
- Forgery fails — without the secret, an attacker can't produce a valid
v1. - Tampering fails — change one payload byte and the HMAC no longer matches.
- Replay fails — the timestamp is inside the signed message, so it can't be refreshed without re-signing. Reject anything older than a tolerance window (5 minutes, Stripe's library default), and cache accepted signatures within that window so an exact replay is caught too. The signature doubles as the nonce: two legitimate deliveries can never share one, because the timestamp differs.
3. Write the receiver
Create server.js:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.WEBHOOK_SECRET;
if (!SECRET) {
console.error('Set WEBHOOK_SECRET first');
process.exit(1);
}
const TOLERANCE_SECONDS = 300; // 5 minutes — same default Stripe uses
const seen = new Map(); // signature -> expiry timestamp (replay cache)
function computeSignature(timestamp, rawBody) {
return createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
}
function verify(req) {
if (!Buffer.isBuffer(req.body)) return 'raw body missing';
const header = req.get('X-Webhook-Signature');
if (!header) return 'missing signature header';
// Header shape: t=1724227200,v1=<hex hmac>
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) {
return 'malformed signature header';
}
// Reject anything outside the freshness window — an attacker can't
// forge a newer timestamp without breaking the signature below.
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (ageSeconds > TOLERANCE_SECONDS) return 'timestamp outside tolerance';
const expected = computeSignature(timestamp, req.body);
const received = Buffer.from(parts.v1, 'hex');
// Length check first: timingSafeEqual throws on unequal lengths.
if (received.length !== expected.length || !timingSafeEqual(expected, received)) {
return 'signature mismatch';
}
// Within the window, the signature itself is the nonce: a valid
// signature we've already accepted means a replayed delivery.
if (seen.has(parts.v1)) return 'replayed delivery';
seen.set(parts.v1, Date.now() + TOLERANCE_SECONDS * 1000);
return null;
}
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const reason = verify(req);
if (reason) {
console.warn(`rejected: ${reason}`);
return res.status(400).json({ error: reason });
}
const event = JSON.parse(req.body); // safe to parse only after verification
console.log(`accepted: ${event.type} (${event.id})`);
res.status(200).json({ received: true });
});
// Prune expired replay-cache entries so memory stays bounded.
setInterval(() => {
const now = Date.now();
for (const [sig, expiry] of seen) if (expiry < now) seen.delete(sig);
}, 60_000).unref();
app.listen(3000, () => console.log('Receiver listening on :3000'));
Two details matter more than they look:
express.raw({ type: 'application/json' })hands you the body as aBuffer— the exact bytes the sender signed. Never useexpress.json()on a webhook route: parse-then-restringify can reorder or reformat, and your HMAC will never match. (Thetypeoption is required here;express.raw's default isapplication/octet-stream.)timingSafeEqualcompares in constant time. A plain===short-circuits at the first differing byte, which leaks how much of a guessed signature is correct — both Stripe's and GitHub's docs explicitly call for constant-time comparison.
4. Write a signing sender to test with
Create send.js — it plays the role of the webhook provider, and delivers the same signed request twice to prove replay rejection:
import { createHmac } from 'node:crypto';
const SECRET = process.env.WEBHOOK_SECRET;
const body = JSON.stringify({ id: 'evt_001', type: 'order.created', amount: 4200 });
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac('sha256', SECRET).update(`${t}.${body}`).digest('hex');
async function deliver(label) {
const res = await fetch('http://localhost:3000/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': `t=${t},v1=${v1}`,
},
body,
});
console.log(`${label}: ${res.status}`, await res.json());
}
await deliver('first delivery ');
await deliver('exact replay '); // same timestamp + signature -> must be rejected
Verify it works
Start the receiver, then run the sender in a second terminal (same WEBHOOK_SECRET exported in both):
node server.js # terminal 1
node send.js # terminal 2
Sender output:
first delivery : 200 { received: true }
exact replay : 400 { error: 'replayed delivery' }
Now try a forged request — valid JSON, garbage signature:
curl -s -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: t=$(date +%s),v1=deadbeef" \
-d '{"id":"evt_002","type":"order.created"}'
{"error":"signature mismatch"}
The receiver log should read:
Receiver listening on :3000
accepted: order.created (evt_001)
rejected: replayed delivery
rejected: signature mismatch
A request with a timestamp older than 300 seconds returns {"error":"timestamp outside tolerance"} even when its signature is valid — that's your replay window working.
Troubleshooting
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length — you passed different-length buffers to timingSafeEqual (it throws instead of returning false). Compare lengths first, as verify() does; length isn't secret, so that check doesn't need to be constant-time.
TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received undefined — req.body was never populated, usually because the request's Content-Type didn't match express.raw's type option, so the middleware skipped it. The Buffer.isBuffer guard converts this crash into a clean 400; make sure your sender sends Content-Type: application/json.
Every request fails with signature mismatch — the classic cause is a global app.use(express.json()) registered before the webhook route: the body arrives already parsed, and whatever you feed the HMAC isn't the bytes the sender signed. Mount express.raw on the webhook route and register that route before any global JSON middleware. Second cause: mismatched secrets — re-check echo $WEBHOOK_SECRET in both terminals.
Valid requests fail with timestamp outside tolerance — your server clock has drifted. Stripe's docs recommend syncing with NTP; never "fix" this by setting the tolerance to zero or to hours.
Next steps
- Read Stripe's webhook signature spec — this tutorial's header format is theirs, and the doc covers secret rotation, where multiple signatures stay valid for 24 hours during rollover.
- Compare GitHub's
X-Hub-Signature-256scheme, which signs only the body with no timestamp — you'd add delivery-ID deduplication for replay protection there. - Swap the in-memory
Mapfor Redis withSET NX EX 300once you run more than one receiver instance; a per-process cache can't catch a replay that lands on a different instance. - Layer on the non-cryptographic defenses: HTTPS-only endpoints, a body-size limit (
express.rawaccepts{ limit: '100kb' }), and provider IP allowlists.
Sources & further reading
- Receive Stripe events in your webhook endpoint — docs.stripe.com
- Validating webhook deliveries — docs.github.com
- Crypto - Node.js documentation — nodejs.org
- body-parser middleware reference — expressjs.com
- express package metadata (latest) — registry.npmjs.org
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.