How to verify webhook signatures with HMAC.

A webhook is an unauthenticated HTTP request from the internet to your server. The signature header is the only thing that proves it came from the provider and was not altered. Here is how the signatures are built, why yours does not match, and how to check one by hand.

Published 2026-09-15 · 8 min read · Kirk Diamond

Why webhooks are signed

Your webhook endpoint is a public URL that accepts POSTs and does something important in response: marks an order paid, deploys code, sends a message. Anyone who knows or guesses the URL can POST to it. TLS proves the request reached you unaltered but says nothing about who sent it. The provider therefore signs each request with a secret only you and they know, and you recompute the signature and compare. If it matches, the body is exactly what they sent. If not, drop it.

How an HMAC signature is built

HMAC is a hash (almost always SHA-256) keyed with a secret. The provider computes HMAC-SHA256(secret, message) and puts the result in a header. The message is usually the raw request body, sometimes with a timestamp prepended. The exact recipe differs by provider, and the differences are where most bugs live:

ProviderHeaderMessageEncoding
StripeStripe-Signature: t=1700000000,v1=..."{t}.{raw body}"hex
GitHubX-Hub-Signature-256: sha256=...raw bodyhex
SlackX-Slack-Signature: v0=..."v0:{X-Slack-Request-Timestamp}:{raw body}"hex
ShopifyX-Shopify-Hmac-Sha256raw bodyBase64
TwilioX-Twilio-Signaturefull URL + sorted POST paramsBase64, HMAC-SHA1
Standard Webhookswebhook-signature: v1,..."{id}.{timestamp}.{raw body}"Base64

Read the provider's documentation for the exact message format. The pattern is always the same; the punctuation is not.

The raw body mistake

Nearly every signature mismatch I have debugged came down to this: the code signed something other than the exact bytes the provider sent. Web frameworks parse the JSON body for you, and if you then re-serialise it (JSON.stringify(req.body), json.dumps(request.json)) you get different bytes: keys reordered, whitespace removed, unicode escaped differently, floats reformatted. The HMAC of those bytes is not the HMAC of the original.

You must have access to the raw request body as bytes, before any parsing. In Express that means express.raw({ type: 'application/json' }) on the webhook route instead of express.json(), or a verify callback that stashes req.rawBody. In Django, request.body. In Go, io.ReadAll(r.Body) before decoding. In Rails, request.raw_post. If a middleware or an API gateway has already consumed and re-encoded the body, the signature can never verify and you need to fix the pipeline.

Verifying step by step

  1. Read the raw body bytes. Do not decode, trim or re-encode.
  2. Read the signature header and, if present, the timestamp header.
  3. Build the message exactly as the provider specifies (for Stripe, timestamp + "." + body).
  4. Compute HMAC-SHA256(secret, message) with the secret as bytes (UTF-8 of the string they gave you, unless they say it is Base64 or hex encoded, in which case decode it first).
  5. Encode the result the way they do: lowercase hex or Base64.
  6. Compare to the header value using a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal, ActiveSupport::SecurityUtils.secure_compare). A plain == leaks timing information that lets an attacker forge a signature byte by byte.
  7. If a timestamp is included, reject requests older than a few minutes. This stops a captured valid request being replayed later.
  8. Only then parse the JSON and act on it.

Reproducing a signature by hand

When it does not match, take the provider out of the loop. Point the webhook at the webhook tester, trigger an event, and copy the raw body and the signature header from the captured request. Then in the HMAC generator: paste the body (or the timestamp-dot-body message) as the text, the signing secret as the key, and the header's signature as the expected checksum. It will tell you which algorithm and encoding match, or that nothing does. If it matches there and not in your code, your code is not signing the same bytes. If it does not match there either, the secret is wrong (rotated, wrong environment, copied with a trailing newline).

# the same thing from a shell
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET"              # GitHub style, hex
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"     # Stripe style
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64   # Shopify style

printf '%s' rather than echo: echo appends a newline and the newline changes the hash.

Other reasons it does not match

Beyond the signature

Verify, then respond quickly (200 within a few seconds) and do the real work asynchronously; providers retry on timeout and you will process events twice. Make handlers idempotent using the event ID, because retries happen even when you did respond. Restrict the endpoint by source IP only if the provider publishes stable ranges (check with the ASN lookup), and treat it as a second layer, not a replacement for the signature.

All guides · All tools