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

// 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

  1. Step 1.

    Start the receiver in its own terminal.

    node stripe-recv.js | tee replay.log
    
    receiver on http://127.0.0.1:8923/stripe
  2. 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
  3. 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.json
    
    timestamp outside the 300s tolerance
    400

    The receiver logs match=true fresh=false. Nothing is wrong with the signature. The request is old, which is what a captured request is.

  4. 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.json
    
    accepted
    200
  5. 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.json
    
    accepted
    200

    Accepted 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.

  6. 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.json
    
    timestamp outside the 300s tolerance
    400
  7. Step 7.

    Read the log, and read the age column rather than the timestamps you chose.

    cat replay.log
    
    receiver 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=false

    Line 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

300 seconds, the five-minute default tolerance Stripe documents for its official libraries, and the value stripe-recv.js enforces on this page Source: https://docs.stripe.com/webhooks#preventing-replay-attacks
a header signed 299 seconds old was read at 303 seconds, four seconds spent between the clock reading and the request landing Source: step 7 of this procedure, node 22.23.2 on this machine, 2026-09-12

Common mistakes

Sign: The signature is verified and the timestamp is read only for logging.Cause: A captured request then works forever. This is the default state of most receivers, because the signature check is the part the provider documentation makes loud. Compare the timestamp with your own clock and reject outside a window, which is one line next to the comparison.
Sign: A boundary test at exactly the tolerance value fails intermittently.Cause: Signing and arrival are not simultaneous. Step 6 signed at 299 seconds and the receiver read 303, so the request meant to land inside the window landed outside it. Test at half the window and at twice it, and assert on the age the receiver logged rather than on the value you signed.
Sign: Deliveries are rejected as stale only during a backlog.Cause: A sender that queues retries can present an event minutes after it was signed, and the signature stays valid the whole time. A window shorter than the sender's retry schedule turns a transient outage into permanent data loss, since each retry is now rejected on age. Match the window to the retry policy you measured.
Sign: Two receivers behind a load balancer disagree about the same request.Cause: Their clocks differ. The age is computed against the local clock of whichever instance answers, so a host that drifts rejects a fraction of traffic while its neighbours accept it. Check that time synchronisation runs on every instance before widening the window to hide it.

What to check next

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.

advanced9 minpublished updated Maks Verny