8 min read·Run it here

Stripe: Verify a Webhook Signature

Stripe's own security model, by hand: parse the Stripe-Signature header, HMAC the timestamp and the raw body, accept any v1 during a rotation, refuse v0, and compare in constant time inside a five minute window.

Pattern
Parse the header, HMAC t + '.' + body, compare every v1 in constant time
Time
O(elements + secrets)
Space
O(signatures)

Your webhook endpoint is a public URL that moves money. The signature is the only thing that separates Stripe from anyone who found that URL in a log. Every framework hands you a one line helper for this, which is exactly why an interviewer asks you to write it: they want to know whether you understand what the helper is protecting you from.

What Stripe actually signs

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39

Comma separated elements. t is the timestamp Stripe signed at, in UNIX seconds. v1 is HMAC-SHA256(endpointSecret, t + "." + rawBody) in lowercase hex. v0 is a fake scheme Stripe attaches to test events so there is something to practise against, and the docs say to ignore every scheme that is not v1.

Say before you type: "Parse the header, rebuild the signed payload as t dot raw body, HMAC it with every secret I hold, and compare it against every v1 in constant time inside a tolerance window."

That sentence is the whole answer. The rest is the four places people get it wrong.

Parse on the first equals, and collect every v1

let timestamp: string | null = null;
const signatures: string[] = [];

for (const element of header.split(",")) {
  const split = element.indexOf("=");
  if (split === -1) throw new Error("Malformed Stripe-Signature header: not a key=value pair.");
  const scheme = element.slice(0, split);
  const value = element.slice(split + 1);
  if (scheme === "t") timestamp = value;
  else if (scheme === "v1") signatures.push(value);
}

Two decisions live in six lines.

push, not assign. During an endpoint secret rotation Stripe signs the same event with both secrets and ships two v1 elements in one header, and any one of them matching is a pass. The natural looking parse is a reduce into { t, v1 }, and it keeps only the last one. That code passes every test you would write by hand, ships, and then fails roughly half of every rotation window with a signature error that looks like a bad secret. Say the word "rotation" out loud while you write push and the interviewer will hear that you have done this before.

else if (scheme === "v1"), not else. A blanket else collects v0 too, and then a v0 that happens to equal your digest verifies. That is a downgrade attack: the attacker picks the weaker scheme and you accept it because you never checked which scheme you were looking at. Stripe's own wording is "to prevent downgrade attacks, ignore all schemes that aren't v1", which is a good sentence to be able to say. v0 is not a fallback. It is a different question you are not answering.

And use the raw body. Not JSON.parse then JSON.stringify, not the parsed object your framework helpfully gave you. Reordering one key or dropping one space changes a byte, and a byte changes the digest. In Express this is the reason for express.raw({ type: "application/json" }) on that route and nowhere else, and it is worth naming.

The window is seconds, and Date.now() is not

const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1000);
const toleranceSec = opts.toleranceSec ?? 300;
if (Math.abs(nowSec - Number(timestamp)) > toleranceSec) return false;

A signature never expires on its own. A valid digest from a request someone captured last year is still a valid digest, which is what the timestamp is for: it pins the signature to a moment, and the tolerance says how far from that moment you will still listen. Stripe's libraries default to 300 seconds.

Three ways to break three lines:

  • t is in seconds and Date.now() is in milliseconds. Forget the / 1000 and the difference is about 1.7 trillion, so every signature you have ever been sent reads as roughly fifty years stale and nothing verifies. The symptom looks exactly like a wrong endpoint secret, which is where people spend the afternoon.
  • opts.toleranceSec || 300. A caller who passes 0 wants zero, and || silently hands them five minutes. ?? or an explicit undefined check. Know the wrinkle before you assert it: stripe-node reads a tolerance of 0 as "skip the recency check entirely", and the docs say not to pass it. The two readings disagree about what 0 means, and they agree completely about ||, which is the part that is a bug either way.
  • nowSec - t > tolerance without Math.abs. Clock skew runs both ways, and a timestamp from the future is as suspect as one from the past. Use the absolute difference and say why.

Both gates apply, every time. A perfect digest outside the window is false, and a wrong digest inside the window is false.

Rotation means a list of secrets

const secrets = Array.isArray(secret) ? secret : [secret];
for (const candidateSecret of secrets) {
  const expected = hmacSha256(candidateSecret, signedPayload);
  for (const provided of signatures) {
    if (timingSafeEqual(expected, provided)) return true;
  }
}

Rotating an endpoint secret is a window, not an instant: you add the new secret, both are live, you cut over, you retire the old one. Your verifier has to hold both for the length of that window, which means the secret is a list even when it has one element in it.

Hoist the digest out of the inner loop. One HMAC per secret, not one per pair. The signature list arrives from the network, so anything you do per element is work an attacker chooses the amount of.

Malformed is a bug. Wrong is an attack.

// Number("") is 0, not NaN.
if (timestamp === null || !/^[0-9]+$/.test(timestamp)) {
  throw new Error("Malformed Stripe-Signature header: no usable t element.");
}

This is the distinction worth having an opinion about, because it is the one that shows judgment rather than recall.

A header that does not parse is a bug: your proxy stripped it, your framework lowercased it into oblivion, someone wired the route up wrong. Return false for that and it disappears into the same counter as real attacks, and you will never find it. Throw, and name the header in the message.

A header that parses and does not verify is an attack, or a rotation you got wrong, or a test event sent to the wrong endpoint. Return false, count it, move on. The v0 only header belongs in this bucket too: it parsed fine, it just carries nothing you are willing to accept.

Number("") is 0, so t= sails straight through a Number.isNaN check and lands your signature in January 1970. Test the string, not the number.

Worth saying out loud: stripe-node does not make this split. It raises a single SignatureVerificationError for all of it, missing timestamp, no signatures with the expected scheme, no match, and outside tolerance alike. Saying "the library folds these into one error, and I would separate the ones that mean my own deploy is broken" is a better answer than either choice on its own.

Constant time, and what that sentence means

function timingSafeEqual(expected: string, provided: string): boolean {
  if (provided.length !== expected.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i += 1) {
    diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
  }
  return diff === 0;
}

expected === provided returns the right answer. It returns it faster when the first character is wrong than when only the last one is, and that difference is a channel. An attacker who can measure it guesses one character at a time: sixteen guesses per position instead of sixteen to the sixty fourth for the whole digest. That is the entire attack, and it is why crypto.timingSafeEqual exists in Node and why Stripe's own libraries call it.

So accumulate with |= and decide at the end. Never return false inside the loop.

The length check before the loop is fine and standard. Lengths are not a secret, the digest is always 64 hex characters, and crypto.timingSafeEqual throws on a length mismatch rather than pretending otherwise.

What the interviewer is listening for

They know you can call stripe.webhooks.constructEvent. What they are checking is whether you can name what it does for you. Four sentences, one each:

  • "Any v1 may match, because a rotation ships two."
  • "v0 is a different scheme and accepting it is a downgrade."
  • "The timestamp is seconds and it exists to bound replay."
  • "The compare is constant time because the alternative leaks the digest one character at a time."

If you can also say which failures are your bug and which are someone else's attack, you are done.