12k
All articles

How to Verify a Webhook Signature

Verify Stripe and GitHub webhook signatures in Express with raw request bodies, constant-time HMAC checks, replay protection, and support for secret rotation.

OpenReplay Team
OpenReplay Team
How to Verify a Webhook Signature

To verify a webhook signature, compute an HMAC-SHA256 digest of the raw request body with your shared secret, then compare it in constant time against the signature the sender put in the request header, and reject the request if they differ.

Your handler has been in production for weeks. Then you add signature checking and every delivery fails with “invalid signature”, even though the secret is definitely right.

A webhook endpoint is a public URL, and anyone can POST to it. Without a signature check, your handler trusts whatever shows up. If you need a refresher on the pattern first, the OpenReplay guide to webhooks covers the basics. This article builds a correct Express handler for Stripe and GitHub. It covers raw-body middleware ordering, constant-time comparison, replay defence and a two-secret rotation check, and explains why each step is there.

Key Takeaways

  • A webhook signature is an HMAC-SHA256 digest of the exact request bytes, so any JSON parsing and re-serialisation before the check will make it fail.
  • In Express, register the webhook route with express.raw({ type: 'application/json' }) before any global app.use(express.json()). Whichever body parser runs first consumes the request stream.
  • crypto.timingSafeEqual throws when its two buffers differ in length, so compare the lengths first and treat a mismatch as an invalid signature.
  • Stripe signs ${t}.${rawBody} and can send one v1 signature per active secret. GitHub signs only the raw body and includes no timestamp, so you deduplicate on the delivery ID.

How Webhook Signature Verification Works

A webhook signature is an HMAC digest the sender computes over the raw request body using a secret that only you and the sender hold. A matching digest proves two things: the payload was not modified, and it came from someone who has that secret. HMAC mixes the secret into a SHA-256 hash, so without the secret nobody can produce a valid digest for a given body. Your server repeats the calculation and compares the results.

The two providers covered here use the same primitive but sign different strings:

StripeGitHub
HeaderStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
Signed string${t}.${rawBody}raw body
Encodinghexhex
Timestamp signedYesNo
Replay defenceReject old tDeduplicate on X-GitHub-Delivery

Stripe documents the header format and signed payload for manual verification. Test events also include a fake v0 signature, so ignore every scheme except v1. GitHub describes its scheme in validating webhook deliveries: the value always starts with sha256=. GitHub still sends the older SHA-1 X-Hub-Signature header, but keeps it only so old integrations keep working.

Read the Raw Body Before Any JSON Parser

The most common reason webhook signature verification keeps failing is a JSON parser that runs before the check. HMAC verification is byte-exact: if you re-serialise the parsed object, any change in whitespace, key order or escaping means the digest will never match.

Middleware order is where it usually goes wrong. In Express, register the webhook route with express.raw() before any global app.use(express.json()). Once the JSON parser has consumed the request stream, the original bytes are gone:

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

Adding express.raw() to the route does not fix the broken version. In body-parser, the first parser to run reads the stream and marks the request as already parsed. Any parser that runs after it skips the request. The route-level raw parser therefore receives nothing, and req.body is still the object express.json() produced.

Add a Buffer.isBuffer(req.body) guard to the handler. That turns a misleading “signature mismatch” into an obvious configuration error. The code here targets Express 5. In Express 5, req.body is undefined when the content type does not match the parser, and in Express 4 it was {}. The guard returns 400 either way.

If you cannot reorder your middleware, the verify option of express.json() gives you the raw Buffer before parsing, and you can save it on req for later. In a Next.js App Router route handler, no body parser runs before your code, so read the bytes with Buffer.from(await request.arrayBuffer()), verify them, and only then call JSON.parse.

Compute the Expected Signature and Compare in Constant Time

Compute the digest with crypto.createHmac('sha256', secret) and pass the Buffer to it directly. Decoding the body to a string and re-encoding it adds an extra step where bytes can change. For Stripe, chain .update(`${t}.`).update(rawBody) so the body goes into the hash untouched.

Webhook signatures must be compared in constant time, not with ===. A string comparison can return at the first character that differs, so its running time leaks how much of the guess was correct. crypto.timingSafeEqual(a, b) takes the same time no matter where the buffers differ. It also throws when the two buffers have different lengths. Check the lengths first so a malformed header counts as an invalid signature and does not crash the handler with a 500:

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

When a signature keeps failing, check these in order:

  1. req.body is a Buffer (log Buffer.isBuffer(req.body)).
  2. The secret belongs to this exact endpoint and mode. Each Stripe endpoint has its own secret, and an endpoint used in both test mode and live mode has a separate secret for each.
  3. If GitHub sends no X-Hub-Signature-256 header at all, the webhook has no secret configured. Check that you are reading the SHA-256 header and not the legacy SHA-1 one, as the GitHub troubleshooting page describes.
  4. The signed string is right: Stripe needs the t. prefix, and GitHub’s sha256= prefix must be stripped.
  5. Both sides are hex. Paste the logged raw body and your secret into the OpenReplay HMAC generator and compare its digest with the header by hand.

Reject Replays with a Timestamp or Delivery ID

A valid webhook signature proves who sent a request, not when it was sent, so a signed request captured once can be replayed later. With Stripe, reject any delivery whose signed timestamp is more than about five minutes old. That matches the default tolerance in Stripe’s libraries. With GitHub, which signs no timestamp, record each delivery ID and skip duplicates.

Check the signature before the timestamp. Until the HMAC over t has been verified, t is just text an attacker can set to anything. Also treat a non-numeric t as a failure: a NaN comparison evaluates to false and would quietly pass a naive freshness check.

GitHub’s X-GitHub-Delivery header holds a GUID for each event, and a redelivery keeps the same GUID. Record the ID only after processing succeeds. GitHub does not retry failed deliveries automatically, and it treats a delivery as failed if it doesn’t get a 2xx within 10 seconds. If you record the ID before a failed run, the manual redelivery you trigger later gets skipped.

Accept Two Secrets During Rotation

During webhook secret rotation, accept a request if any currently valid secret produces a digest that matches any v1 value in the header. When you roll a Stripe endpoint secret, the old secret can stay valid for a period you choose, up to 24 hours. Until it expires, every delivery carries a separate v1 signature for each secret that is still valid.

A Stripe header parser that collapses the header into an object keeps only one v1 and rejects valid deliveries, so collect every value into an array. For GitHub, try each configured secret against the single header. The some() loops return early on a match, and that leaks nothing useful: each individual comparison is still constant-time.

The Complete Express Handler

This ESM handler combines every step. It returns 400 for any signature or format failure (senders only distinguish 2xx from non-2xx) and 200 for GitHub duplicates, which are acknowledged and skipped:

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

Every guard handles a specific failure. A missing header returns 400 and does not throw. Every v1 value is kept. Length checks run before timingSafeEqual. A non-Buffer body is caught as a configuration error. For Stripe-only apps, the official library’s stripe.webhooks.constructEvent performs the same checks for you, and the Stripe webhooks docs show how to call it.

Conclusion

Webhook signature verification comes down to hashing the exact bytes that arrived and comparing the result safely. The rest of the handler exists to keep those bytes intact, stop replays and survive secret rotation. Move your webhook routes above express.json() first, then add the handler above. If you are still deciding whether push delivery suits your integration, webhooks vs. polling compares the tradeoffs.

FAQs

Why does Stripe signature verification fail when testing locally with the Stripe CLI?

The Stripe CLI uses its own signing secret, which is different from the secret of any endpoint created in the Stripe Dashboard. Both start with whsec_, so they are easy to mix up. When you run stripe listen, the CLI prints its secret in the terminal. Use that value for events the CLI forwards, and never verify CLI-forwarded events with a Dashboard endpoint secret or the other way around.

Should I use Stripe's constructEvent or verify the signature manually?

Use stripe.webhooks.constructEvent if Stripe is your only provider. You pass it the raw request body, the value of the Stripe-Signature header and your endpoint secret, and it throws an error when the signature does not check out. By default, Stripe's libraries reject a signed timestamp that is more than 5 minutes away from the current time, and an optional argument lets you set a different window. Manual verification makes sense when GitHub or other providers share the same code path.

Why use HMAC instead of hashing the secret and body together with SHA-256?

HMAC resists length-extension attacks, which break a naive construction such as SHA-256 of the secret followed by the body. SHA-256 uses the Merkle-Damgard design, so anyone who holds a valid digest can append data to the message and compute a valid digest for the longer message without knowing the secret. HMAC hashes the key in two nested passes, which blocks this. Stripe and GitHub both sign with HMAC-SHA256.

Do I still need HTTPS if I verify webhook signatures?

Yes. An HMAC signature protects integrity and authenticity, not confidentiality. Without TLS, anyone on the network path can read the payload, which may contain customer or payment data, and can capture a signed request to replay later. Signature checks, replay defences and HTTPS each cover a different threat, so a production webhook endpoint needs all three.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.