Webhook Security: Signature Verification and Replay Protection
A webhook receiver must decide whether a delivery came from expected sender and whether it is still safe to process. TLS protects transport, not a replayed signed request. RFC 9421 defines HTTP message signatures; provider-specific schemes still need raw-body verification.
Verify bytes before parsing
Compute MAC or verify signature against original request bytes, selected headers, timestamp, and key identifier before JSON parsing. A framework that reserializes JSON can change whitespace and break the signed representation. Example: accept event.created once, then reject same event ID on retry. Example: a valid signature over an old payout event must not trigger a second credit.
Receiver worksheet
| Control | Test | Expected evidence |
|---|---|---|
| signature | altered byte | rejection before business handler |
| freshness | timestamp outside window | no queue message |
| replay | repeated event ID | idempotent result |
| key rotation | prior and current key | bounded overlap only |
CWE-294 covers replay weaknesses; RFC 9421 explains signed message components.
Decision rule: do not enqueue or mutate state until authenticity, freshness, and idempotency checks pass.
Repair order
Preserve raw body, use constant-time comparison, persist deduplication keys atomically with side effects, then test clock skew and provider retries. Sender contracts determine exact headers and retry policy; this is not a substitute for them. Source review can trace handler boundaries.
Build an admission boundary
A receiver should capture raw bytes before any body parser transforms them. Select only provider-documented headers and algorithm, bind a timestamp or expiry to the delivery, and resolve the active key from an explicit key identifier. Reject malformed metadata before cryptographic work. Compare MAC values in constant time. A signature proves message integrity for its covered representation; it does not decide which events are valid business transitions.
A practical handler uses a single database transaction: reserve provider plus event ID with a uniqueness constraint, create intended state change, and commit once. If duplicate reservation wins, return provider-compatible success without repeating side effect. Store receipt time, provider event ID, signature key ID, freshness verdict, and handler result. Do not log body secrets. If queueing occurs, enqueue only after admission and carry idempotency key into consumer.
Evidence and exception record
Release evidence needs altered-body rejection, expired-delivery rejection, duplicate-event proof, current-key acceptance, retiring-key behavior, and proof that failed checks created no state. Owner: integration service owner. Pass: all invalid deliveries stop before queue or state mutation. Fail: any failed check reaches handler. An exception names provider, affected event type, compensating control, risk owner, remediation owner, and expiry.
Receiver request example
A provider profile may require POST to a fixed hook route plus event ID, timestamp, key ID, and signature headers. Capture raw bytes, derive signed input exactly as profile requires, and verify before parsing. Return generic failure without revealing failed component. During rotation accept named current and retiring keys only for recorded overlap.
Webhook receipt ledger
Expected output: raw SHA-256 digest, key ID, provider event ID, received-at value, signature verdict, freshness verdict, transaction outcome. This receipt makes signature and replay decisions independently auditable.
Worked failure: A body signed for event A arrives with event ID B. Verification must fail before JSON parsing; a valid old event must reserve no new business row.
Edge case: Clock skew can be legitimate during provider recovery. Keep a documented tolerance, but never let tolerance replace event-ID uniqueness.
Closure test: Replay a captured valid delivery after its first commit. Closure passes only when response is idempotent and side-effect count remains one.
Delivery lifecycle drill
Configure a test sender with two event IDs and one business object. Send the first delivery, repeat it before the first handler completes, then repeat it after commit. Expected outputs are one committed object transition, one durable receipt row, and provider-safe acknowledgements for duplicates. Next alter a byte after signing and confirm no parser error, queue message, or retryable business error obscures the signature rejection. Test key retirement by signing an identical fixture with current, retiring, and unknown key IDs. The unknown identifier must fail without searching an unbounded key store.
Retention matters because deduplication cannot last forever without a stated event horizon. Derive receipt retention from provider retry window and business reversal risk, then document what happens after expiry. If delayed events are allowed, route them through a review state rather than silently bypassing freshness checks. The final artifact is a handler test showing transaction rollback when receipt reservation or business mutation fails.
A receiver must bind method and target route to its provider profile when those components matter to dispatch. Otherwise a valid signature intended for one hook could be accepted by another handler. Capture canonical route before middleware rewriting. The regression fixture should prove that a signed request sent to an adjacent route cannot reserve an event receipt. If a provider does not sign route information, use a separate endpoint secret or receiver key and document that boundary.