Stripe webhook signature verification

The signed string is not the body. It is the timestamp, a dot, and then the raw body, so the header reads t=1789167242,v1=ce7a1138…. Sign those 126 bytes and your receiver answers 200. Sign the 115 bytes of the body alone and it answers 400 on every delivery, with a correct HMAC.

Why check this

Run this before a payment integration takes its first live event, and again whenever the endpoint moves behind a new proxy. The failure it prevents is the one that looks like a broken secret and is not: a receiver that computes a perfectly correct HMAC over the wrong bytes and rejects every event, so paid orders never reach the fulfilment queue. The scheme is published, so the whole check runs locally, against a secret you pick, with no account and no sandbox credentials.

Prerequisites

// stripe-recv.js - verifies a Stripe-format signature over "<timestamp>.<raw body>".
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'));
// bodyonly-recv.js - the same receiver with one line wrong: it signs the body alone.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = 'whsec_test_do_not_use_a_real_one';
http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(String(req.headers['stripe-signature'] || ''));
    const want = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
    console.log(`sent=${v1 ? v1[1] : '(none)'}\nwant=${want}`);
    res.writeHead(v1 && v1[1] === want ? 200 : 400).end();
  });
}).listen(8928, '127.0.0.1', () => console.log('body-only receiver on http://127.0.0.1:8928/stripe'));
// sign-stripe.mjs - builds a Stripe-format signature header for a body on disk.
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 stripe.log
    
    receiver on http://127.0.0.1:8923/stripe
  2. Step 2.

    Start the broken one beside it, in a third terminal. Running both is the point of the procedure: the difference between them is one argument to one function.

    node bodyonly-recv.js | tee bodyonly.log
    
    body-only receiver on http://127.0.0.1:8928/stripe
  3. Step 3.

    Build the header. The signer prints the length of what it signed to stderr and the header value to stdout.

    T=$(date +%s)
    node sign-stripe.mjs event.json "$T"
    
    signed payload: 126 bytes, the 10-character timestamp and a dot in front of the 115 bytes of the body
    t=1789167242,v1=ce7a11382df9cb865d353ffbed572a155bd41663d5fed32ec1f1a1c7878644f8

    126 bytes against the 115 the sender transmits. Those 11 extra bytes are the whole procedure.

  4. Step 4.

    Deliver the event to the receiver that signs the timestamp with the body.

    SIG=$(node sign-stripe.mjs event.json "$T" 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: $SIG" --data-binary @event.json
    
    accepted
    200
  5. Step 5.

    Send the identical request to the receiver that signs the body alone.

    curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8928/stripe' -H 'content-type: application/json' -H "stripe-signature: $SIG" --data-binary @event.json
    
    400

    Its terminal shows why, and the two values have nothing in common:

    sent=ce7a11382df9cb865d353ffbed572a155bd41663d5fed32ec1f1a1c7878644f8
    want=4e802723de48f92965b5c0e3992668245f692e4ca6cfe6f37ded898db01e8fc6

    This receiver has the right secret, the right algorithm, the right encoding and the right body. It will reject every event it is ever sent, and the error it reports is the same one a wrong secret reports.

  6. Step 6.

    Change the amount from 2599 to 199 and send the header from step 3 unchanged.

    curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $SIG" --data-raw '{"id":"evt_3Ab","type":"payment_intent.succeeded","data":{"object":{"id":"pi_3Ab","amount":199,"currency":"usd"}}}'
    
    signature mismatch
    400
  7. Step 7.

    Read the receiver log. The signed column separates a tampered body from a receiver that signs the wrong string.

    cat stripe.log
    
    receiver on http://127.0.0.1:8923/stripe
    t=1789167242 age=0s signed=126B match=true fresh=true
    t=1789167242 age=2s signed=125B match=false fresh=true

    The second delivery signed 125 bytes rather than 126, because the tampered body is one byte shorter. A receiver that prints only match=false sends you looking for a secret that was never wrong.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 200 and match=true | The receiver signs the timestamp and the body | Move on to the tolerance window and to duplicate events. | | 400 on every delivery, match=false | The signed string is wrong, usually the body alone | Concatenate the timestamp, a dot, then the raw bytes, in that order. | | signed is 11 bytes more than the body | The prefix is present | A 10-digit timestamp plus a dot. A different number here means the timestamp was formatted in milliseconds. | | signed equals the body length | The prefix is missing | The receiver hashes only the body. This is the bug the third terminal reproduces. | | header unparsable | The header never arrived, or a proxy rewrote it | Log the raw header value before parsing it. | | 400 with fresh=false and match=true | The signature is correct and the timestamp is old | That is the replay window, not a signature problem. |

Common mistakes

Sign: Every event is rejected and rotating the secret changes nothing.Cause: The receiver signs the body and the scheme signs the timestamp, a dot, then the body. The HMAC is correct and the input is not, so the symptom is identical to a wrong secret. Step 5 reproduces it with the right secret in place: 126 bytes against 115.
Sign: Verification works in a unit test and fails behind the reverse proxy.Cause: The test hands the verifier a string, while the running service hands it whatever the body parser produced. Take the raw bytes, hash them, and only then parse. The raw body page measures the difference the parser makes.
Sign: The timestamp in the header is thirteen digits long.Cause: The scheme uses Unix seconds. A signer that passes Date.now() writes milliseconds, which changes the signed string, the age comparison and the tolerance check at once. The signer on this page prints the character count of the timestamp so the mistake shows up before the request is sent.
Sign: A scheme with several signature versions in one header verifies against the wrong one.Cause: The header is a comma-separated list and can carry more than one scheme version. Match the version your receiver implements by name rather than taking the last value, and treat an unknown version as a rejection rather than as a match.

What to check next

FAQ

How do I test Stripe webhooks locally?

Sign a payload with a secret you choose and post it to your own endpoint, as steps 3 and 4 do. That covers the signed string, the tolerance and the handler. A live account and its forwarding tool are needed only for the final pass with real events.

Where does the Stripe webhook signing secret come from?

The endpoint's page in the provider dashboard, one secret per endpoint, different in test and live mode. This procedure uses a local secret instead, because the scheme is what is being checked and the secret is an input to it.

What exactly gets signed?

The timestamp from the header, an ASCII dot, then the raw request body, concatenated as bytes. Step 3 prints the total: 126 bytes for a 115-byte body with a 10-digit timestamp.

Is a matching signature enough to process the event?

No. It proves the body and the secret. Freshness needs the timestamp check, and duplicate delivery needs the event id. A verified event can still be the third copy of one you already booked.

Can I verify without the provider's library?

Yes. The scheme is published and the receiver on this page is 26 lines. Use the library in production where it exists, and keep this receiver for isolating a failure the library reports as a single boolean.

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.

intermediate10 minpublished updated Maks Verny