The Webhook Security Contract
A webhook receiver is an unauthenticated public POST endpoint that triggers business logic — unless the contract says how events are signed, how timestamps bound replay, and how secrets rotate. Signature verification is the consumer's only proof that an event is yours.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
An unsigned webhook endpoint is an open command port
Follow the trust boundary: the consumer's receiver must be reachable from the public internet (the provider calls it from their infrastructure), so *anyone* can POST to it. If the handler trusts the body, the attack is one curl: forge {"type":"payment.succeeded","data":{"order_id":"ord_mine"}} at a store's webhook URL and walk away with unpaid goods. The endpoint URL is not a secret — it leaks through logs, referrers, browser history and misconfigured monitoring — and defenses built on hiding it are defenses built on nothing.
The non-solutions are worth naming because each ships constantly. A static token in the URL (?token=abc123) is a password logged by every proxy on the path. Source-IP allowlists break when the provider moves infrastructure and provide nothing against an attacker in the same cloud; they are defense-in-depth at best. TLS proves the *consumer's* identity to the provider and encrypts the channel — it does nothing to prove to the consumer who sent the request. The channel being private says nothing about who is on the other end of it; that boundary reasoning is the Trust Boundaries discipline applied to an inbound edge you were not thinking of as one.
- HTTPS only — the provider must refuse to deliver to
http://URLs; secrets and payloads otherwise transit in the clear. - Signature scheme — algorithm (HMAC-SHA256), exactly what is signed (timestamp + raw body), header format, and versioned scheme id (
v1=). - Timestamp tolerance — the window (commonly 5 minutes) outside which a valid signature is still rejected.
- Secret provisioning and rotation — per-endpoint secrets, how rotation overlaps, how test-mode secrets differ from live.
- Verification code — reference implementations in the docs; every consumer who hand-rolls verification is a future incident.
The signature: HMAC over timestamp + raw body
The standard scheme: provider and consumer share a per-endpoint secret; each delivery carries a header with a timestamp and HMAC-SHA256(secret, timestamp + "." + raw_body). The consumer recomputes and compares. A valid MAC proves two things at once — the sender holds the secret (authenticity) and the body is byte-identical to what was signed (integrity). The timestamp inside the signed string is what stops replay: an attacker who captures a legitimate delivery cannot re-send it next week, because the aged timestamp fails the tolerance check, and cannot freshen the timestamp, because that breaks the MAC.
The implementation traps are boringly consistent. Verify over the raw request bytes, not a re-serialized parse — JSON parsers reorder keys and normalize whitespace, and any byte difference is a false rejection (or worse, teams "fix" it by disabling verification). Compare with a constant-time function, not ==, or the comparison's timing leaks the MAC byte by byte. Enforce the timestamp window *and* keep Consumer-Side Idempotency's event-id dedup: the window shrinks the replay surface to minutes, dedup closes it — a replayed event inside the window is a duplicate event_id and drops at the gate.
POST /hooks/payments HTTP/1.1
Host: consumer.example
Content-Type: application/json
X-Webhook-Signature: t=1787648043,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{"event_id":"evt_8f2c1a","type":"payment.succeeded",...}
consumer verifies:
1. |now − t| ≤ 300s → else reject 400
2. expected = HMAC_SHA256(secret, t + "." + raw_body)
3. constant_time_eq(expected, v1) → else reject 400
4. dedup on event_id → replays inside the
window die hereRotation, multiple secrets, and living with verification
Secrets leak — into a git repo, a log line, a departed contractor's laptop — so rotation must be routine, and routine rotation requires overlap. The provider signs with the new secret while the consumer accepts both old and new for a bounded window (verify against each active secret; some providers send multiple v1= values in one header). Rotation without overlap is a synchronized deploy across two organizations, which means in practice it never happens and three-year-old secrets accumulate. The contract should state who initiates rotation, how long both secrets stay valid, and that per-endpoint secrets keep one leak from compromising every consumer.
Two operational clauses finish the contract. First: verify before anything else — before parsing, before dedup lookups, before logging the body — so forged traffic costs one HMAC and cannot probe your handler's behavior. Second: signature verification failures deserve their own metric and alert; a spike is either an attack, a consumer's broken deploy, or a botched rotation, and all three want a human. The cryptographic background — why HMAC rather than a bare hash, what MACs do and do not prove — is Security Engineering's ground; the contract's job is choosing the clauses and making them verifiable by every consumer, forever.
1# provider docs: "keep your webhook URL secret"2POST /hooks/pay?token=abc1233 4def handle(req):5 if req.query.token != "abc123": # logged everywhere,6 return 403 # rotates never7 apply(parse(req.body)) # body: unverified8 9# any proxy log leaks the token; a leaked URL+token10# = full event-forging capability, silently, forever1def handle(req):2 t, sig = parse_sig_header(req.headers["X-Webhook-Signature"])3 if abs(now() - t) > 300:4 return 4005 for secret in active_secrets: # old + new during rotation6 if constant_time_eq(sig,7 hmac_sha256(secret, t + "." + req.raw_body)):8 return accept(req) # dedup, persist, enqueue9 metrics.incr("webhook.bad_signature")10 return 400The good version derives trust from a rotatable shared secret and the message bytes themselves — nothing an observer of the traffic or the logs can reuse. The bad version derives trust from things that leak by design (URLs, query strings) and offers no integrity: anyone who ever saw the token owns the endpoint.
Key points
- A webhook receiver is publicly reachable by construction; without signatures, anyone who finds the URL can forge business events with one curl.
- URL secrecy, query tokens and IP allowlists are not authentication — they leak by design or break on provider infrastructure changes; IP lists are defense-in-depth at most.
- HMAC over timestamp + raw body gives authenticity, integrity and replay-bounding in one header; verify raw bytes, compare constant-time.
- The timestamp window shrinks replay to minutes; event-id dedup closes the remainder — the two defenses are designed to be used together.
- Rotation requires overlap: consumers verify against all active secrets so two organizations never need a synchronized deploy.
- Verify before parsing or logging, and alert on signature-failure spikes — attack, consumer break and botched rotation all surface there.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Provider → contract: ships webhooks with "keep your endpoint URL secret" as the entire security section.
- 2Consumer → handler: trusts the body; the endpoint URL sits in deploy logs, monitoring configs, and a browser history.
- 3Attacker → endpoint: finds the URL, forges
payment.succeededfor their own order id, receives the goods. - 4Consumer → provider: disputes the "payment" the provider never sent; neither side can distinguish forged deliveries from real ones after the fact.
- 5Provider → retrofit: adds signatures in v2 — and must run unsigned v1 delivery for years, because verification cannot be forced onto consumers who never built it.
- Forged events trigger real business effects: unpaid orders fulfilled, accounts provisioned, subscription states overwritten.
- Replayed legitimate events re-trigger effects outside any fraud-detection window when neither timestamps nor dedup bound them.
- A single leaked global signing secret (or query token) compromises every consumer at once, and without rotation machinery the fix is a coordinated emergency across all of them.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Sign every delivery: HMAC-SHA256 over `timestamp + "." + raw_body`, versioned header scheme, per-endpoint secrets — from v1, because verification cannot be retrofitted onto consumers later.
- • State the tolerance window (≈5 minutes) and require event-id dedup in the same doc section, as one replay defense with two layers.
- • Support overlapping active secrets and a self-serve rotation flow; document who rotates, and how long overlap lasts.
- • Ship reference verification code for major languages and refuse `http://` endpoint URLs at registration time.
- • Signature-failure rate per endpoint: baseline near zero, spikes on attack, consumer deploy breakage, or mis-executed rotation.
- • Deliveries rejected for timestamp skew reveal consumers with clock drift before they turn into "your webhooks stopped working" tickets.
- • Secret age per endpoint: a dashboard of never-rotated secrets is a queue of incidents waiting for their leak.
- • Version the scheme in the header (`v1=`) so a future algorithm or canonicalization change ships as `v2=` alongside `v1=`, with both emitted during the migration window.
- • Never change what bytes are signed within a scheme version — a canonicalization "fix" breaks every consumer's verification simultaneously; that change is exactly what the version tag is for.
- • Tolerance windows can widen without breaking consumers; narrowing them needs notice, since consumers with marginal clock sync are relying on the slack.
- • Signing and verification add a shared-secret lifecycle both sides must operate — provisioning, storage, rotation — which is real friction against "just POST me the JSON".
- • Raw-body verification constrains consumer frameworks: middleware that parses or re-encodes bodies before the handler breaks verification, and fighting the framework is where many teams give up and disable it.
- • Strict timestamp windows punish consumers with poor clock discipline; every rejection is correct and still generates a support ticket.