HMAC webhook signatures
Published: 2026-09-05
How providers sign webhook bodies with HMAC (SHA-256 and friends), why exact bytes and header formats matter, Stripe-style t=/v1= headers, and how to verify signatures locally without uploading secrets.
HMAC (Hash-based Message Authentication Code) is how most webhook providers prove that a POST body came from them and was not altered in transit. The provider and your server share a signing secret. The provider hashes the request body (sometimes with a timestamp prefix) under that secret and sends the digest in a header. Your endpoint recomputes the same HMAC and accepts the event only if the digests match.
This guide covers what HMAC actually protects, why “almost the same JSON” fails verification, how common header formats look, and how to debug signatures locally so the secret never hits a third-party site.
What HMAC proves (and what it does not)
HMAC answers one question: did someone who knows this secret produce this exact message?
| Property | HMAC webhook signature |
|---|---|
| Integrity | Yes — any byte change in the signed message flips the digest |
| Authenticity | Yes — relative to holders of the shared secret |
| Confidentiality | No — the body is still plaintext HTTP |
| Replay protection | Only if you also check timestamps / nonces the provider defines |
Anyone who steals the webhook secret can forge valid signatures. Treat whsec_… and similar keys like passwords: rotate on leak, never commit them, and prefer verifying with a local tool over pasting secrets into random “signature checkers.”
HMAC is not encryption. For authenticated encryption of payloads you control end-to-end, see AES-GCM basics (nonce, tag, AAD). TOTP also uses HMAC under the hood, but for short time-based codes rather than request bodies — What is TOTP?.
The signed message is exact bytes
Providers sign the raw body bytes they sent, not “the logical JSON object.” These all produce different HMACs even when a human reads them as the same event:
- Extra spaces or a trailing newline
- Pretty-printed vs minified JSON (
{"a":1}vs{\n "a": 1\n}) - Unicode normalization or BOM differences
- Signing
timestamp.payloadvs signingpayloadalone
When debugging a mismatch, paste the exact body your framework received (or the raw string from logs / a packet capture), not a re-serialized copy from your ORM. If you pretty-print for readability, minify again before signing — LocalTools’ verifier can minify valid JSON for that reason.
Algorithms and encodings
Most modern providers use HMAC-SHA-256. Older or niche APIs may still use SHA-1, SHA-512, or (rarely) HMAC-MD5. The hash choice must match the provider docs; guessing wrong looks identical to a wrong secret.
The digest is binary. Headers expose it as:
| Encoding | Looks like | Common in |
|---|---|---|
| Hex (often lowercase) | a3f2… (64 chars for SHA-256) |
Many custom APIs, GitHub-style sha256= |
| Base64 | o/I…= |
Some enterprise / SOAP-era stacks |
Comparing hex to Base64 (or uppercase hex to lowercase) without normalizing will always “fail” even when the underlying bytes match.
Header shapes you will see
Providers wrap the same idea differently:
# Bare digest
X-Signature: a3f2c9…
# Algorithm prefix (GitHub-style)
X-Hub-Signature-256: sha256=a3f2c9…
# Stripe-style timestamp + versioned signature
Stripe-Signature: t=1614556800,v1=a3f2c9…
For Stripe-style schemes, two details matter:
- The header may list
t=(Unix seconds) and one or morev1=digests. Verifiers often extract thev1=token for comparison. - The signed string is usually
t+.+ raw body, not the body alone. If you HMAC only the JSON, a correct Stripe secret will still mismatch.
Always read the provider’s “construct the signed payload” section. LocalTools’ HMAC & Webhook Verifier pulls v1= (and simple sha256= hex) out of pasted header values for comparison; you still supply the same message string the provider signed (including timestamp. when required).
Verification workflow (server or local)
A typical secure endpoint does roughly:
- Read the raw body (before JSON parse mutates whitespace).
- Read the signature header and the shared secret from config.
- Build the signed message exactly as the provider documents.
- Compute HMAC with the documented algorithm.
- Compare digests in constant time (avoid early-exit string
===on long hex). - Optionally reject events whose timestamp is too old (replay window).
When an integration fails in staging, step through the same math offline: secret + message + algorithm + encoding → digest, then compare to the header. Doing that in-tab with Web Crypto keeps the secret on your machine — see Why “local only” matters for developer tools.
Common pitfalls
- Re-serializing JSON — pretty-print or key reordering changes the HMAC.
- Wrong signed string — body-only vs
t.bodyvssecret + bodyschemes differ by provider. - Encoding mismatch — hex vs Base64, or URL-safe Base64 vs standard.
- Secret formatting — some dashboards show
whsec_prefixes that belong in the key material; others expect the decoded bytes. Follow the provider’s decode step. - Framework buffering — middleware that parses body first may leave you without the original bytes.
- MD5 “for compatibility” — prefer SHA-256 when you control both sides; MD5 HMAC is weak for new designs.
Try it in the browser
Use HMAC & Webhook Verifier to:
- Paste the raw webhook body (or minify JSON so it matches what was signed).
- Enter the signing secret and choose SHA-256 / SHA-512 / SHA-1 / HMAC-MD5 plus hex or Base64.
- Paste the provider header (bare digest,
sha256=…, or Stripe-stylet=…,v1=…) and confirm match vs mismatch locally.
Related reading: Generating passwords and tokens in the browser for strong shared secrets, AES-GCM basics (nonce, tag, AAD) when you need encryption as well as authenticity, and What is TOTP? for another everyday HMAC construction.