Webhook replay attack
Sign one event with a timestamp ten minutes old and post it: a correct signature comes back 400, match=true fresh=false. Sign the same event a minute old and post it twice: both copies come back 200. The window bounds how long a captured request stays usable, and it stops no replay inside that window.
Why check this
Run this once an endpoint verifies signatures, because a verifier that ignores the timestamp is the state most receivers ship in. A request captured from a proxy log, a mirrored staging endpoint or a shared test tunnel stays valid forever, so a refund event replayed at will keeps issuing refunds. Two numbers settle the design: how long a captured request remains acceptable, and what the handler does with the second copy of an event it already booked. This procedure measures the first and shows exactly where the second one starts.
Prerequisites
- Node 22, the receiver
stripe-recv.jsand the signersign-stripe.mjs. The receiver enforces a 300 second tolerance and prints the age of every timestamp it reads. - curl, for the five deliveries.
- A local secret,
whsec_test_do_not_use_a_real_one, and an event body inevent.json, 115 bytes with no trailing newline. - Port 8923 free. Confirm with
netstat -ano | grep 8923, and stop the receiver afterwards. - A clock within a few seconds of correct. The check compares a timestamp the sender wrote with the receiver's own clock, so skew on either machine moves the result.
// stripe-recv.js - verifies the signature over "<timestamp>.<raw body>", then the age.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.WEBHOOK_SIGNING_SECRET || 'whsec_test_do_not_use_a_real_one';
const TOLERANCE = 300;
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const header = String(req.headers['stripe-signature'] || '');
const t = /(?:^|,)t=(\d+)/.exec(header);
const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(header);
if (!t || !v1) { console.log('header unparsable: ' + header); return res.writeHead(400).end('no t= or v1=\n'); }
const signed = Buffer.concat([Buffer.from(t[1] + '.', 'utf8'), raw]);
const want = crypto.createHmac('sha256', SECRET).update(signed).digest('hex');
const a = Buffer.from(v1[1], 'utf8'), b = Buffer.from(want, 'utf8');
const match = a.length === b.length && crypto.timingSafeEqual(a, b);
const age = Math.floor(Date.now() / 1000) - Number(t[1]);
const fresh = Math.abs(age) <= TOLERANCE;
console.log(`t=${t[1]} age=${age}s signed=${signed.length}B match=${match} fresh=${fresh}`);
if (!match) return res.writeHead(400).end('signature mismatch\n');
if (!fresh) return res.writeHead(400).end(`timestamp outside the ${TOLERANCE}s tolerance\n`);
res.writeHead(200).end('accepted\n');
});
}).listen(8923, '127.0.0.1', () => console.log('receiver on http://127.0.0.1:8923/stripe'));
// sign-stripe.mjs - builds a signature header for a body on disk, at a timestamp you choose.
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
const secret = process.env.WEBHOOK_SIGNING_SECRET || 'whsec_test_do_not_use_a_real_one';
const raw = readFileSync(process.argv[2]);
const t = process.argv[3] || String(Math.floor(Date.now() / 1000));
const signed = Buffer.concat([Buffer.from(`${t}.`), raw]);
console.error(`signed payload: ${signed.length} bytes, the ${t.length}-character timestamp and a dot in front of the ${raw.length} bytes of the body`);
console.log(`t=${t},v1=${createHmac('sha256', secret).update(signed).digest('hex')}`);
Steps
- Step 1.
Start the receiver in its own terminal.
node stripe-recv.js | tee replay.logreceiver on http://127.0.0.1:8923/stripe - Step 2.
Take one reading of the clock and build a header dated ten minutes before it. Every later step is measured from this
NOW.NOW=$(date +%s) node sign-stripe.mjs event.json $((NOW-600))signed payload: 126 bytes, the 10-character timestamp and a dot in front of the 115 bytes of the body t=1789166848,v1=3d5f22a97881ffb501d645fdf1cf73fa5f08054e35b53fe1bf9bbd798e6b67a9 - Step 3.
Deliver the stale event.
OLD=$(node sign-stripe.mjs event.json $((NOW-600)) 2>/dev/null) curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $OLD" --data-binary @event.jsontimestamp outside the 300s tolerance 400The receiver logs
match=true fresh=false. Nothing is wrong with the signature. The request is old, which is what a captured request is. - Step 4.
Sign the same event one minute old and deliver it.
RECENT=$(node sign-stripe.mjs event.json $((NOW-60)) 2>/dev/null) curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $RECENT" --data-binary @event.jsonaccepted 200 - Step 5.
Send that request again, byte for byte, with no change to the header.
curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $RECENT" --data-binary @event.jsonaccepted 200Accepted twice. A signature check and a tolerance window together do not stop a replay, they bound it: whoever holds the captured request has 300 seconds to use it as many times as they like. The remaining defence is the handler, which has to recognise the event id it already processed.
- Step 6.
Probe the edge of the window with a timestamp one second inside it.
EDGE=$(node sign-stripe.mjs event.json $((NOW-299)) 2>/dev/null) curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $EDGE" --data-binary @event.jsontimestamp outside the 300s tolerance 400 - Step 7.
Read the log, and read the
agecolumn rather than the timestamps you chose.cat replay.logreceiver on http://127.0.0.1:8923/stripe t=1789166848 age=600s signed=126B match=true fresh=false t=1789167388 age=62s signed=126B match=true fresh=true t=1789167388 age=63s signed=126B match=true fresh=true t=1789167149 age=303s signed=126B match=true fresh=falseLine four is the one worth the procedure. The header was built 299 seconds old and the receiver read it at 303 seconds, because four seconds passed between the clock reading in step 2 and the request landing. Lines two and three show the same drift in miniature, 62 seconds and then 63. A timestamp is a claim about when a request was signed, and the receiver measures when it arrived. An edge test that expects an exact boundary fails for reasons that have nothing to do with the receiver, so test with margin and read the age the receiver logged.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| match=true fresh=false | A correct signature outside the window | Working as intended. Confirm the sender retries rather than dropping the event. |
| match=true fresh=true twice for one request | The replay went through | Add deduplication on the event id. The window alone does not cover this. |
| Every delivery fresh=false | Clock skew, or a timestamp in milliseconds | Compare both clocks, and check the digit count. Unix seconds is 10 digits until 2286. |
| A negative age | The sender's clock is ahead | Allow the window in both directions, which Math.abs does here, and raise the skew with whoever owns the sender. |
| age larger than the value you signed | Time passed between signing and arrival | Expected. Test a few seconds inside the boundary, not on it. |
| No timestamp in the header at all | The scheme has no replay window | Add one at the application layer, or dedupe on the event id and an arrival time you record. |
Thresholds
Common mistakes
What to check next
- Stripe webhook signature verification: where the timestamp in this header comes from and what it signs.
- Webhook idempotency: the defence that covers the replay inside the window.
- How to check webhook signature: the signature check this procedure assumes is already in place.
- How to test API idempotency: the same property stated for a request rather than for an event.
- How to test webhook delivery: the retry schedule your window has to be wider than.
FAQ
How long should the replay window be?
Wider than the sender's retry delays and narrower than an attacker's convenience. 300 seconds is the documented default for two major providers and a reasonable start. Measure the retry schedule first, because a window shorter than it drops legitimate retries.
Does a timestamp check stop replay attacks?
It bounds them. Steps 4 and 5 accept the same request twice inside the window. Deduplication on the event id is what makes the second copy harmless, and the window is what keeps a copy from being useful next week.
Why is the timestamp inside the signature rather than a separate header?
Because a value outside the signature can be edited. The timestamp is part of the signed string, so changing it invalidates the signature and an old request cannot be given a fresh date.
What if the sender and receiver clocks disagree?
The age is computed from the receiver's clock, so skew shifts every verdict. Allow the window in both directions, log the age on every request, and treat a fleet of receivers with different times as a correctness problem rather than as a tuning problem.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: webhook-signature compute and compare an HMAC-SHA256 signature for a payload and secret (Stripe, GitHub, generic formats) with WebCrypto
- Payment gateway testing checklist
- All payments and webhooks checks
advanced9 minpublished updated Maks Verny