HMAC-Signed Webhooks: One Primitive, Three Dialects
Three of the webhook receivers I run in production verify an HMAC signature before processing a single byte - a card processor's settlement posts, Mandrill message events, and service-to-service webhooks my own apps sign. After implementing all three, the surprise is how little variety there is. It's one primitive wearing three outfits, and the bugs are never in the crypto. They're in what you feed it.
The problem the signature solves is blunt: a webhook endpoint is a URL on the public internet that mutates your system, and anyone can POST to it. A receiver that trusts unauthenticated JSON will record settlements for whoever finds the URL. The fix is a shared secret: the sender computes an HMAC over material both sides can reconstruct, puts it in a header, and the receiver recomputes and compares. One check buys authenticity and integrity.
The three dialects
| Dialect | Signing base | Seen in |
|---|---|---|
| Raw body | HMAC-SHA256 over the exact request bytes | GitHub's X-Hub-Signature-256; the service-to-service webhooks my own apps sign |
| Timestamped | HMAC-SHA256 over "<t>.<body>", sent as t=...,v1=... |
Stripe; my card processor's settlement webhook (a nonce variant of the same shape) |
| URL + sorted params | base64 HMAC-SHA1 over the public webhook URL with every POST param appended in key order | Twilio's X-Twilio-Signature, Mandrill's X-Mandrill-Signature |
The second dialect exists for a reason the other two ignore: replay. A captured raw-body request verifies forever - the signature has no notion of when. Prefixing a timestamp and rejecting anything outside a tolerance window (Stripe defaults to five minutes) bounds how long a recorded request stays usable.
The five gotchas that actually bite
- Verify the raw bytes. The signature covers the exact bytes on the wire. Framework middleware that parses JSON and hands you a re-serialized object has already destroyed the signing base - key order, whitespace, and unicode escapes all shift. Capture the raw body, verify it, then parse.
- Compare in constant time.
hash_equalsin PHP,hmac.compare_digestin Python,crypto.timingSafeEqualin Node. A plain==against an attacker-supplied signature is a timing oracle that leaks how many leading characters matched. - URL dialects sign the sender's view of the URL. Twilio and Mandrill signed
https://app.example.com/webhooks/mandrill, but behind a proxy your app sees an internal scheme, host, and port. Reconstruct the public URL from configuration, not from the request. This one bites the first time you deploy behind a proxy, because it worked fine in development. - Tolerance windows cut both ways. Too tight and ordinary clock skew rejects legitimate traffic; absent and you have no replay defense. Check the timestamp in both directions - a request from the future is as wrong as one from last week.
- Fail closed. A missing header, a malformed header, or - the scary one - an unset signing key in your own config must reject the request. Design against the receiver that silently stops verifying and keeps processing.
A sixth gotcha earned its place while I wrote this. The opening line first claimed every receiver I run verifies a signature, and fact-checking that against the codebase proved it false. Older receivers, written before I held this standard, turned up without verification and became the next hardening item. Receivers you wrote years ago don't inherit the discipline you have today.
What the signature doesn't give you
A valid HMAC authenticates the sender. It does not authorize the payload's contents - your business rules still apply. It does not dedupe - Mandrill retries on any non-200, so event-level idempotency is your job. And it does not replace TLS - the signature proves origin, but the payload still deserves encryption in transit.
The verifiers, extracted
I pulled all three dialects into webhook-verify, a zero-dependency TypeScript library: verifyGitHub, signTimestamped/verifyTimestamped with a replay-tolerance window, verifyTwilio, and verifyMandrill, every comparison constant-time, with 27 tests covering the tamper, replay, and malformed-header cases above.
The part I haven't solved is shared-secret sprawl: one secret per sender per environment, each a credential to store, inject, and rotate. Asymmetric webhook signatures (Ed25519, as Svix uses) fix the distribution problem - the receiver holds only a public key - but none of the providers I depend on offer them. For now the honest answer is a secrets manager and rotation discipline, which is a process, not a primitive.