← Debugging Guides

阅读中文版本

Webhooks — HMAC Signature Validation Fails After No Code Change

Fix webhook signature verification failures caused by body parsing order, encoding, and timestamp tolerance, and compare digests in constant time.

intermediatedebuggingwebhookshmacsecurity

Symptoms

  • Webhook receivers reject valid payloads: signature mismatch errors while the provider insists it signs correctly
  • Failures start after a framework upgrade or middleware addition, with no signature logic touched
  • Some events validate and others fail, correlating with payload size or content (unicode, newlines)

Possible causes

  • Body-parsing middleware consumed and re-serialized the stream before signature verification read the raw bytes
  • The HMAC is computed over the parsed-and-restringified JSON, which differs from the signed original bytes
  • Hex versus base64 encoding mismatch between the provider header and the receiver comparison
  • Timestamp tolerance rejects legitimate replays within clock skew, or raw body access differs by route

Diagnosis steps

Verify against the provider documented scheme

curl -s https://docs.provider.com/webhooks | grep -A20 -i "signature"

Interpretation

The scheme defines: which header, which encoding (hex/base64), which fields join the signed string, and the timestamp format — implement exactly that.

Capture the raw body before parsing

app.use('/webhooks', express.raw({ type: 'application/json' })) # before json parser on this route

Interpretation

If the raw bytes are gone by the time you verify, no algorithm fixes it: capture order is the bug.

Reproduce with a known payload

node -e "const c=require('crypto'); const raw=process.argv[1]; console.log(c.createHmac('sha256',process.env.SECRET).update(raw).digest('hex'))" -- '{"a":1}'

Interpretation

A locally computed digest over the exact received bytes, compared to the header, isolates encoding versus ordering versus secret problems.

Root cause

The signature is computed over the exact raw bytes the provider sent. When middleware parses and re-serializes the body first, the verifier hashes different bytes and fails. The same class of bug appears as encoding mismatches (hex compared against base64) or timestamp windows that reject providers within legitimate clock skew.

Fix

Capture the raw body before any parser on the webhook route, verify against the provider documented scheme with exact encoding, include timestamp in the signed string when the scheme requires it with a realistic tolerance window, and compare digests with constant-time equality.

Risk note

Comparing with === leaks digest length and timing, and replaying without timestamp checks lets old webhooks re-execute. Use timingSafeEqual and bounded freshness.

Verify the fix

A replayed signed sample validates, a tampered byte fails, an expired timestamp fails, and parsing middleware order is covered by a regression test.

Symptom walkthrough

The failure pattern indicts infrastructure rather than cryptography: no code changed, but a framework upgrade or a new body-parsing middleware broke every webhook. The mechanism:

provider signs:  {"a":1,"b":2}          (exact bytes)
middleware does: req.body -> JSON.stringify -> {'b':2,'a':1}
verifier hashes: {"b":2,"a":1}          (different bytes)
HMAC mismatch — always

The encoding twin is equally mechanical: comparing a hex digest against a base64 header fails for every event. And the size correlation (small events pass, large ones fail) points at parser limits truncating the stream before verification.

Fix in practice

Route-ordered raw capture, then exact-scheme verification:

const crypto = require('node:crypto');

// 1. raw bytes BEFORE any JSON parser, on this route only:
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // 2. exact scheme: hex, over the raw body
    const expected = crypto
      .createHmac('sha256', process.env.STRIPE_SECRET)
      .update(req.body)                    // Buffer: the exact bytes
      .digest('hex');

    const provided = req.headers['stripe-signature'];

    // 3. constant-time compare, with a length pre-check
    const a = Buffer.from(expected);
    const b = Buffer.from(String(provided));
    const ok = a.length === b.length && crypto.timingSafeEqual(a, b);

    if (!ok) return res.status(400).end();
    handle(JSON.parse(req.body.toString()));
  });

For timestamp-bearing schemes (Stripe t=…,v1=…), parse the parts, check t within a window of a few minutes to absorb clock skew, then verify the digest over timestamp + body exactly as documented.