Test Webhook Signature Verification
Sign payloads the way the provider does, then assert that tampering, replay, and timestamp skew are rejected before any handler runs.
Task
Verify that webhook endpoints accept only correctly signed, fresh payloads and reject modified or replayed requests.
When to use this
A webhook handler trusts the JSON body. An attacker posts a fake payment success event, the order is marked paid, and the signature check exists only in documentation.
Prerequisites
- Node.js 22 with node:crypto
- Raw request body access, before JSON parsing
- A shared secret and an event store for deduplication
Steps
1. Verify an HMAC over the timestamp and the exact raw body, with a tolerance window.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
Expected output: A stale, malformed, or tampered header returns false before any JSON parsing or business logic.
2. Prove the happy path with a signature generated exactly like the provider's.
const sign = (body, t = Math.floor(Date.now() / 1000)) =>
`t=${t},v1=${createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')}`;
const body = JSON.stringify({ type: 'payment.succeeded', id: 'evt_1' });
const res = await request('/webhooks/payments', {
body,
headers: { 'stripe-signature': sign(body) },
});
assert.equal(res.status, 200);
Expected output: 200, and exactly one event row is written for evt_1.
3. Tamper with the body after signing and confirm rejection.
const tampered = body.replace('"evt_1"', '"evt_2"');
const res = await request('/webhooks/payments', {
body: tampered,
headers: { 'stripe-signature': sign(body) },
});
assert.equal(res.status, 400);
Expected output: 400, and no event is stored because verification fails before the handler.
4. Reject stale timestamps and deduplicate repeated deliveries by event id.
const post = (header, body) => request('/webhooks/payments', {
body,
headers: { 'stripe-signature': header },
});
const stale = sign(body, Math.floor(Date.now() / 1000) - 600);
assert.equal((await post(stale, body)).status, 400);
await post(sign(body), body);
await post(sign(body), body);
assert.equal(await countEvents('evt_1'), 1);
Expected output: A ten-minute-old signature is rejected, while two deliveries of the same event produce one record.
5. Verify against the raw bytes: re-serializing the JSON must break the signature.
const raw = '{"amount":100, "currency":"usd"}';
const reserialized = JSON.stringify(JSON.parse(raw));
assert.notEqual(reserialized, raw);
const res = await request('/webhooks/payments', {
body: reserialized,
headers: { 'stripe-signature': sign(raw) },
});
assert.equal(res.status, 400);
Expected output: Whitespace changes are enough to fail verification, so the handler must use the raw body it received.
Troubleshooting
Problem
Verification always fails behind a proxy
Solution
A body parser consumed and re-encoded the JSON. Capture the raw body with a raw parser before any JSON middleware and pass those bytes to the verifier.
Problem
The header contains multiple v1 signatures during secret rotation
Solution
Accept the request if any v1 value matches, comparing each in constant time. Verify with both old and new secrets during rotation.
Problem
A replay inside the tolerance window is processed twice
Solution
Deduplicate by event id with a unique constraint, and record the id before performing side effects.
Limitations
- The tolerance window trades clock skew against replay exposure; monitor provider clock drift.
- A valid signature proves the sender, not that the event is semantically valid or current.
- Signature schemes differ between providers; implement and test per provider rather than assuming Stripe's format.
Unsigned webhooks are an open endpoint
A webhook URL is public by design. If the handler trusts the body, anyone who learns the URL can mark orders paid, cancel subscriptions, or trigger refunds. A shared secret plus an HMAC over the payload is the minimum control: it proves the request came from someone who knows the secret, and it binds the signature to the exact bytes so a modified amount invalidates it.
Sign the timestamp, not just the body
Signing ${timestamp}.${body} lets the receiver reject old captures. Without a timestamp, a valid signature captured once is valid forever, and an attacker can replay it at will. The tolerance window, commonly five minutes, must accommodate clock skew between the provider and your servers, so keep both sides on NTP and alert on drift.
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
Compare in constant time
A naive === comparison leaks information about the expected signature through timing, which can be exploited over enough requests. Use timingSafeEqual, and guard the length check first because the function throws on buffers of different lengths. During secret rotation the header may contain several v1 values; accept if any matches, but still compare each in constant time.
Verify before parsing
The verifier needs the raw bytes. If a JSON middleware runs first, the body is re-serialized and the signature no longer matches, or worse, the handler verifies a parsed object and skips the check. Capture the raw body for the webhook route only, verify, then parse. Test the re-serialization case explicitly, because it is the failure that appears when someone reorders middleware.
Deduplicate separately
A valid signature does not prevent the provider from delivering the same event twice; retries are normal. Persist the event id with a unique constraint and record it before side effects, so a duplicate is a no-op. Together, signature verification and deduplication give you authenticity and exactly-once effects, which are two different guarantees.