Understanding Stripe Webhook Signatures: HMAC-SHA256, Replay Protection, and Constant-Time Verification

When your application receives a webhook from Stripe, it needs a way to answer a simple but important question:

“Did this request really come from Stripe, and has it been changed?”

Stripe solves this using a message authentication scheme based on HMAC-SHA256. Even though Stripe calls the value in the header a “signature,” it is not a digital signature in the public/private key sense. Instead, it is a symmetric authentication scheme.

That means Stripe and your server both share the same secret. Stripe uses that secret to generate a cryptographic tag over the webhook payload, and your server uses the same secret to verify it.

There is no public key. There is no private key pair. There is one shared signing secret.

The Core Primitive: HMAC-SHA256

At the center of Stripe webhook verification is this operation:

crypto
  .createHmac("sha256", secret)
  .update(message)
  .digest("hex");

This computes an HMAC, or Hash-based Message Authentication Code, using SHA-256 as the underlying hash function.

Conceptually, HMAC turns a normal hash function into a keyed authentication mechanism. A plain SHA-256 hash can tell you whether data changed, but anyone can compute it. HMAC is different because it requires a secret key. Without that key, an attacker cannot generate a valid authentication tag for a new message.

The simplified structure looks like this:

HMAC(K, m) =
SHA256((K ⊕ opad) || SHA256((K ⊕ ipad) || m))

Where:

K = the shared secret key
m = the message being authenticated
ipad = inner padding constant
opad = outer padding constant

That nested construction is important. A naive scheme like this would be unsafe:

SHA256(secret || message)

That kind of construction can be vulnerable to length-extension attacks. HMAC avoids that problem by wrapping the hash in a carefully designed inner and outer structure.

With SHA-256, the final output is 256 bits. When rendered as hexadecimal, that becomes a 64-character string.

The security property you get is message authenticity. Without the shared secret, an attacker should not be able to produce a valid tag for a message they have not already seen signed.

That is what allows your webhook endpoint to trust that a valid request came from someone who knows the Stripe signing secret.

What Stripe Actually Signs

Stripe does not just sign the JSON body by itself. The signed message is built from two pieces:

Buffer.concat([
  Buffer.from(parts.t + ".", "utf8"),
  rawBuf
]);

In plain terms, the authenticated message is:

timestamp + "." + raw_request_body_bytes

Or:

signed_payload = t || "." || body

This detail matters a lot.

The timestamp is included inside the MAC. Because of that, an attacker cannot change the timestamp without invalidating the HMAC. This is what connects the replay protection check to the cryptographic verification. When your server checks the timestamp, it can trust that the timestamp is the same value Stripe signed.

The body must also be verified as raw bytes, not as a parsed and re-encoded JSON string. This is one of the most important implementation details.

Webhook verification is byte-exact. Even a single changed byte produces a completely different HMAC. That means this:

rawBuf

is safer than this:

JSON.stringify(req.body)

If Express parses the JSON first, reformats whitespace, changes escaping, normalizes characters, or otherwise alters the body representation, your server may end up verifying different bytes than Stripe originally signed.

That is why webhook handlers should capture the untouched request body using something like express.raw() before JSON parsing happens.

Parsing the Stripe-Signature Header

Stripe sends the authentication data in the Stripe-Signature header.

It looks like this:

Stripe-Signature: t=<unix_ts>,v1=<hex_tag>

Sometimes there may be more than one v1 value:

Stripe-Signature: t=<unix_ts>,v1=<hex_tag>,v1=<another_hex_tag>

The t value is the Unix timestamp. The v1 values are HMAC tags.

Multiple v1 tags are supported because Stripe may include more than one valid tag during secret rotation. For example, during a rotation window, Stripe may sign the same payload with both an old and new secret so applications can transition safely.

A typical parser splits the header on commas, then splits each part on the first equals sign. From there, it extracts:

t  = timestamp
v1 = one or more candidate HMAC tags

Your server then computes the expected HMAC locally and checks whether any provided v1 value matches.

Replay Protection

HMAC proves that a request is authentic and unmodified. But by itself, it does not prove that the request is fresh.

An attacker who captures a valid webhook request could try replaying that same request later. Since the body and HMAC are unchanged, the authentication tag would still be valid.

That is why Stripe includes a timestamp in the signed payload.

A typical replay check looks like this:

const t = Number(parts.t);

if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
  return false;
}

This enforces a five-minute freshness window.

The check rejects requests where the timestamp is more than 300 seconds away from the server’s current time. Because the timestamp is part of the signed message, an attacker cannot simply edit the timestamp to make an old request look fresh.

The Number.isFinite(t) guard is also important. Without it, a malformed timestamp could become NaN. In JavaScript, this kind of comparison can fail open:

Math.abs(Date.now() / 1000 - NaN) > 300

That evaluates to:

NaN > 300

Which is false.

So without an explicit finite-number check, a bad timestamp could accidentally bypass the replay check. The guard makes sure malformed timestamps are rejected instead of silently accepted.

Constant-Time Comparison

After your server computes the expected HMAC, it has to compare that value against the v1 values from the Stripe header.

A tempting but unsafe approach would be:

candidate === expected

The problem is that ordinary string comparison can short-circuit. It may stop as soon as it finds the first differing character. That can leak timing information about how much of the guessed tag was correct.

A safer approach is:

crypto.timingSafeEqual(
  Buffer.from(candidate),
  Buffer.from(expected)
);

timingSafeEqual compares the two values in time that does not depend on how many leading bytes match. This helps prevent timing side-channel attacks where an attacker tries to learn the correct tag byte by byte.

There is one catch: timingSafeEqual requires both buffers to be the same length. If they are not, it throws.

That is why the comparison is usually wrapped in a try/catch:

parts.v1.some((v) => {
  try {
    return crypto.timingSafeEqual(
      Buffer.from(v),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
});

If a candidate tag is malformed or the wrong length, it is simply rejected.

Using .some() allows the request to pass if any supplied v1 tag matches the locally computed expected value. This supports Stripe’s secret rotation behavior, where more than one tag may be valid during a transition period.

The End-to-End Trust Chain

A secure Stripe webhook verification flow looks like this:

1. Receive the raw request bytes.
2. Extract the timestamp and v1 tags from the Stripe-Signature header.
3. Reconstruct the signed payload:
   timestamp + "." + raw body bytes
4. Reject the request if the timestamp is malformed or outside the freshness window.
5. Compute HMAC-SHA256 using your Stripe webhook secret.
6. Constant-time compare the expected tag against each supplied v1 tag.
7. Only after verification succeeds, parse the body and act on the event.

The order matters.

You should not parse and trust the JSON body first. You should first verify that the raw bytes are authentic, unmodified, and recent. Only then should your application process the event.

What Security Guarantee Does This Provide?

When implemented correctly, Stripe webhook verification gives your endpoint three important guarantees.

First, the request is authentic. It came from a party that knows the shared Stripe webhook secret.

Second, the body is unmodified. The HMAC covers the exact raw request bytes, so any byte-level change breaks verification.

Third, the request is recent. The timestamp check limits replay attacks to a narrow time window, and because the timestamp is authenticated, attackers cannot alter it without invalidating the tag.

The constant-time comparison also prevents a subtle but real class of timing leaks during tag verification.

Common Mistakes to Avoid

The most common mistake is verifying a re-encoded body instead of the raw request body. JSON parsing can change the byte representation, even when the data means the same thing semantically. HMAC does not care whether two JSON objects are equivalent. It only cares whether the bytes are identical.

Another mistake is checking the HMAC but skipping the timestamp window. That leaves the endpoint open to replay attacks.

A third mistake is using a normal equality check instead of a timing-safe comparison. While this may seem harmless, authentication checks should avoid leaking information through timing behavior.

Finally, malformed input should never fail open. Missing timestamps, non-numeric timestamps, malformed signatures, wrong-length tags, or missing headers should all result in rejection.

Final Thoughts

Stripe webhook signatures are a strong and practical example of symmetric message authentication.

The scheme is not complicated, but the details matter. The secret must stay secret. The body must be verified as raw bytes. The timestamp must be authenticated and checked. The final comparison must be timing-safe.

When all of those pieces are in place, your webhook endpoint can safely trust that an incoming Stripe event is authentic, byte-exact, and recent before your application acts on it.

0 comments

Leave a comment