All articles
webhook securityHMACsignature verificationbest practices

Webhook Signature Verification, Without the Mystery

Most webhook signature failures aren't crypto bugs. They're parsing bugs. Somebody parsed the body before verifying. Somebody used hex when the provider wanted base64. Somebody compared with === when they needed a timing-safe compare. This guide is the short version of every signature problem we've ever debugged.

The model providers use

Every major webhook provider — Stripe, GitHub, Shopify, Twilio, Slack, Discord — does the same thing in different clothing:

  1. You and the provider share a secret.
  2. The provider computes a signature over the request body (sometimes plus timestamp, sometimes plus URL).
  3. The signature ships in a header.
  4. Your handler recomputes the signature using the same secret and rejects the request if they don't match.

What changes across providers: the algorithm (HMAC SHA-256 is most common; Discord uses Ed25519), the encoding (hex vs base64), what gets signed (body, body+timestamp, URL+body+params), and which header carries the signature.

The four bugs that account for almost everything

1. Parsing the body first

Most frameworks have a JSON middleware that parses incoming bodies into objects. The moment it runs, the raw bytes are gone, and any signature you compute won't match — even if you re-stringify the parsed object, the whitespace and key order can differ.

Fix: read the raw body before any middleware on the webhook route. In Express, mount express.raw({ type: 'application/json' }) only on the webhook path. In Next.js App Router, read await request.text() directly. In Django, use @csrf_exempt + request.body before any DRF parser runs.

2. Wrong encoding

Stripe and GitHub use hex. Shopify uses base64. The same secret produces different-looking outputs in each. We've already covered this in the Shopify guide — it bites everyone.

3. Not timing-safe

String equality (===) returns early on the first mismatched character. The time difference between "match the first character" and "match all 64 characters" leaks information about the secret. Use a timing-safe comparison:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

In practice the attack is hard to mount against a webhook endpoint over the public internet, but using the secure comparison is so cheap there's no reason not to.

4. Timestamp not validated

If the signature only covers the body, an attacker who captures a valid request can replay it forever. Providers solve this by including a timestamp in the signature payload and a tolerance window in the docs (Stripe: 5 minutes, GitHub: also short). Your handler should reject any signature with a timestamp older than the tolerance.

Skip this step and you've built a replay-vulnerable system, no matter how perfectly the HMAC works.

What's actually in the header

Provider headers carry more than just "the signature". Stripe sends t=1234567890,v1=abcdef...,v0=... — timestamp plus multiple signature versions. GitHub sends sha256=abcdef... with a prefix. Twilio includes the request URL in the signing payload but only ships the signature itself in X-Twilio-Signature. Read the docs for the provider you're integrating against, then implement against the exact format.

Local testing makes this debuggable

Signature verification is the worst kind of bug: silent and hard to reproduce on staging. Local testing with a tunnel and request capture gives you the exact payload that failed, the exact header that arrived, and a replay button to re-test the fix without bothering the provider.

When the verification rejects something, log enough to debug: the raw body's length, the header you read, the signature you computed. Don't log the secret. (We've seen people log secrets. Don't.)

A minimal correct verifier

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

When signature verification still fails

If everything above is right and it still fails, three more things to check: the secret has trailing whitespace from your env file, your tunnel has rewritten a header (most don't, but worth confirming with request capture), or you're testing against the live secret while the dashboard shows the test secret. We've hit all three.

For provider-specific gotchas, see the Stripe, GitHub, and Shopify guides. Or if you're staring at a 401 right now, jump to debugging webhook 401 errors. Join the PortPreview waitlist for tunnel + capture that makes this debuggable.

Frequently asked questions

Why is my webhook signature verification failing?
Most of the time the body was parsed by middleware before verification ran. Other common causes: hex versus base64 encoding mismatch, non-timing-safe comparison failing on certain edge cases, or using the wrong secret (test vs live, dashboard vs CLI).
Do I really need timing-safe comparison for webhook signatures?
Yes. Plain string equality leaks timing information about the secret one byte at a time. The attack is hard to mount in practice but the secure comparison is one line of code, so there's no reason not to use it.
Why does my webhook signature include a timestamp?
Without a timestamp, an attacker who captures a valid signed request can replay it forever. The timestamp lets your handler reject anything older than the provider's tolerance window, typically a few minutes.