// concept

HMAC webhook signatures — what they prove and how to verify them

Updated 2026-05-09

The setup

A webhook URL is publicly reachable. Anyone on the internet can POST to it. So if a request arrives claiming "Stripe charged $50,000", how does your handler know it actually came from Stripe — not someone trying to mark fake orders as paid?

The answer is HMAC: the provider signs the request body with a shared secret, and your handler verifies the signature using the same secret.

What HMAC is

HMAC ("Hash-based Message Authentication Code") combines a hash function with a secret key in a specific way that's safe against length-extension attacks and other classical traps. The recipe:

HMAC(K, m) = H((K' xor opad) || H((K' xor ipad) || m))

Where:

  • H is a hash function (SHA-256 in 2026)
  • K is the secret key
  • m is the message
  • opad, ipad are constants

You don't need to remember the formula — every standard library has hmac. What matters: it's a one-line call.

const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex')

What HMAC proves

If you and the provider share a secret SECRET, and the provider computes HMAC(SECRET, body), then anyone who computes the same HMAC must also know SECRET. So a matching signature on an inbound request proves: the body was signed by someone holding the secret.

If you keep the secret secure (env var, secret manager) and the provider keeps it secure on their side, then the signature confirms the request came from them.

It does not prove:

  • That the request is fresh (replay attacks possible — see timestamps below).
  • That the request is the same one the user expected (CSRF-like risks — match against your application state).

How webhook providers send the signature

Three header layouts in common use:

  • Stripe: Stripe-Signature: t=<unix>,v1=<hex> — HMAC over <t>.<body>. Includes a timestamp.
  • GitHub: X-Hub-Signature-256: sha256=<hex> — HMAC over the raw body.
  • Shopify: X-Shopify-Hmac-Sha256: <base64> — HMAC over the raw body, base64-encoded.

/tools/webhook-signature-verifier handles the differences for you.

The four classic ways verification fails

In every Stripe / GitHub debugging session I've sat through, the cause is one of:

1. Body mutation

Your framework parsed the JSON before signature verification. The re-serialized body has different whitespace than what the provider signed.

// Wrong — express.json() ate the bytes
app.use(express.json())
app.post('/webhook', (req, res) => {
  verify(req.body, ...)  // req.body is an object now
})

// Right — raw bytes for the webhook route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  verify(req.body, ...)  // req.body is a Buffer
})

2. Header parsing

You treated the whole header as the signature without stripping the prefix.

// Wrong
const sig = req.headers['x-hub-signature-256']  // "sha256=abc..."

// Right
const sig = req.headers['x-hub-signature-256'].slice(7)  // "abc..."

3. Encoding mismatch

Comparing hex against base64 (or vice versa). Shopify is the usual culprit because devs assume hex.

4. Timing attack on string compare

a === b short-circuits on the first mismatched character; the time difference is observable. Use crypto.timingSafeEqual() (Node) or the equivalent in your language.

Replay attacks

A signature alone doesn't say when the message was signed. An attacker who recorded a legitimate webhook earlier could replay it. Mitigations:

  • Timestamps — Stripe, Slack, others include a timestamp in the signed payload. Reject if it's > 5 minutes old.
  • Idempotency — record the event ID; if you've already processed it, skip.
  • Both — combine for safety.

Verify it interactively

Stop debugging signature mismatches by reading docs. /tools/webhook-signature-verifier lets you paste body + header + secret and tells you in plain English what's wrong: secret, body whitespace, header parsing, encoding, or stale timestamp. Pure client-side; the secret never leaves your browser.

// shipping?

lrok gives your localhost a public HTTPS URL with a reserved subdomain on the free plan. $9/mo flat for unlimited.

Related