What is a signature verification?
Signature verification is the receiver-side step that checks an inbound webhook's signature against the expected HMAC of the raw body. The correct shape: read the raw bytes before any JSON parser touches them, read the signature header, check the timestamp tolerance if the scheme includes one, recompute `HMAC_SHA256(secret, signed_payload)` matching the provider's exact format, and compare with a constant-time compare. The five most common bugs: parsing the body before verifying (Express `json()` runs first, byte sequence changes), wrong secret loaded, wrong signed-payload format (Stripe signs `<timestamp>.<body>`, not just `<body>`), standard string compare leaking timing info, and missing timestamp check.
Verification is the act of taking the inbound webhook and proving it came from the source. The mechanics are straightforward — recompute the HMAC, compare to the header — but the failure modes are surprisingly subtle, and signature verification is the single most error-prone step in webhook integration.
The correct shape:
1. Read the raw request body as bytes, before any JSON parser touches it.
2. Read the signature header (Stripe-Signature, X-Hub-Signature-256, etc.).
3. If the provider's scheme includes a timestamp in the signed payload (Stripe does, GitHub doesn't), check that the timestamp is within tolerance of "now" — typically ±5 minutes — to defend against replay.
4. Compute HMAC_SHA256(secret, signed_payload) where signed_payload matches the provider's exact spec.
5. Compare the computed digest to the header value with a constant-time compare.
6. If match: parse the body, process the event. If not: return HTTP 401 immediately, log the failure (with event ID, not the secret).
The five most common verification bugs, in roughly the order they cause production outages:
- Parsed body, not raw body. express.json() runs before your verification code, the body is already parsed into a JS object, you stringify it back, and the byte sequence has changed (whitespace, key order, escape sequences). Every Stripe integration hits this once. Fix: use express.raw({ type: 'application/json' }) for webhook routes, parse manually after verifying.
- Wrong secret loaded. Secret rotation went out of order, env var was set on the wrong service, or the test secret is in prod by accident. Symptom: 100% of webhooks fail verification suddenly. Fix: log a fingerprint of the loaded secret on boot.
- Wrong signed payload format. Stripe signs <timestamp>.<body>, not just <body>. If you skip the timestamp prefix, you compute the wrong HMAC. Read the provider's spec carefully.
- Standard string compare instead of constant-time compare. Works most of the time; leaks timing info to a sophisticated attacker. Use crypto.timingSafeEqual (Node) or hmac.compare_digest (Python).
- No timestamp tolerance check. Without it, an attacker who captures one valid signed request can replay it months later.
When verification fails, never silently 200. Always return 401, never include the expected signature in the response, log enough detail to debug (event ID, timestamp delta, "expected vs got" digest fingerprints — never the full digests or the secret).
See Signature Verification in real traffic
WebhookWhisper captures every webhook with full headers, body, signature, and timing — so concepts like signature verification stop being abstract and become something you can inspect.
Start Free