Verifying webhook signatures with HMAC
How webhook signing works, the four reasons valid events fail verification, and how to check signatures safely in production.
A webhook is an HTTP request that another service sends to your server when something happens: a payment succeeds, a repository is pushed, a form is submitted. Because your endpoint is a public URL, anyone who discovers it can send fake events. Signature verification is how you tell a genuine event from a forged one. When verification fails for real events, the cause is almost always one of a handful of mistakes, which this guide walks through.
How HMAC signatures work
When you register a webhook, the provider gives you a signing secret that only the two of you know. For every event, the provider computes an HMAC, a keyed hash, over the request body using that secret. It sends the result in a header such as X-Hub-Signature-256 (GitHub) or Stripe-Signature (Stripe). Your server repeats the calculation with its copy of the secret and compares the two values. If they match, the body was produced by someone who knows the secret and has not been modified in transit.
HMAC-SHA256 is the common choice. Its output is 32 bytes, usually sent as 64 hexadecimal characters or as 44 Base64 characters. Changing a single character of the body, even adding a space, produces a completely different signature.
A worked example
With the secret demo-webhook-secret and the body
{"event":"note.created","id":"note_42"}the HMAC-SHA256 signature in hexadecimal is
eca86d87e73da960106e2b2a93d31a508297cd2ffa9f3d64476f479d454d51baPaste those three values into the webhook signature verifier and it reports a match. Add a space after the colon in the body and it reports a mismatch, even though the JSON means the same thing.
Why valid events fail verification
1. The body was parsed and re-serialized
This is the most common cause by far. Frameworks like Express, Next.js, and Django often parse JSON bodies automatically. If you verify against JSON.stringify(req.body), key order, whitespace, and number formatting may differ from the bytes the provider signed. Always verify the raw request body. In Express that means express.raw({ type: 'application/json' }) on the webhook route. Other frameworks have their own raw-body options.
2. The provider signs more than the body
Many providers include extra data in the signed string to prevent replay attacks. Stripe signs timestamp.body and puts both the timestamp and signature in one header (t=…,v1=…). Slack signs v0:timestamp:body. GitHub prefixes the header value with sha256=, which you must strip before comparing. Read the provider’s documentation for the exact string to sign.
3. Wrong encoding or algorithm
A hex signature compared against a Base64 computation will never match, and neither will SHA-256 against SHA-1 or SHA-512. Check both settings against the documentation. The verifier lets you choose either encoding and several algorithms, which makes it easy to test which combination a provider uses.
4. The wrong secret
Test mode and live mode, or staging and production, usually have different secrets. Some providers also generate a separate secret per endpoint. Secrets copied with a trailing space or newline fail silently.
Verification in production code
- Compare signatures with a constant-time function, such as
crypto.timingSafeEqualin Node.js orhmac.compare_digestin Python. An ordinary string comparison can leak, through timing, how many leading characters matched. - Reject events whose timestamp is more than a few minutes old, so a captured request cannot be replayed later.
- Make handlers idempotent. Providers retry deliveries, so the same event ID can arrive more than once.
- Return a 2xx status quickly and do slow work in the background. Timeouts cause retries.
- Keep the secret in environment configuration and rotate it if it leaks. Most providers support two active secrets during rotation.
Testing without exposing the secret
When a signature fails in production, you need to find out whether the problem is the body, the secret, or the encoding. The verifier runs HMAC with the browser’s Web Crypto API. The secret is imported as a non-extractable key for the duration of the check, and it is left out of every result and download. Nothing is sent over the network. Capture the raw body exactly as received, for example from your server’s logs before parsing, and test it together with the header value.
A match proves that the sender knew the secret. It does not prove the event is recent, unique, or correct. Those checks belong in your handler. For inspecting the rest of the incoming request, the HTTP header parser lists every header with sensitive values redacted.
