Webhook Security: HMAC Signatures and Replay Protection Done Right
A webhook endpoint is an unauthenticated door into your automation. Verify HMAC over the raw body, enforce timestamp tolerance, and block replays — with AI drafting the middleware.
- #automation
- #ai
- #webhooks
- #security
- #hmac
Every webhook endpoint you expose is a public URL that triggers internal automation, and unless you verify what arrives, anyone who learns that URL can fire that automation. I have seen a “harmless” webhook receiver — it just kicked off a cache invalidation — get discovered by a scanner and hammered into a self-inflicted denial of service. The automation behind a webhook is real, so the request reaching it has to be proven authentic, fresh, and unique before a single handler runs.
Those three words are the whole job, and teams routinely deliver only the first. Authenticity says the sender holds the shared secret. Freshness says the request was made recently, not captured last week. Uniqueness says this exact request hasn’t been replayed. An HMAC check alone gives you authenticity and nothing else, which is why so many “signed” webhook endpoints are still replayable. AI is well-suited to drafting this middleware — the patterns are standard — but the bypasses are subtle, so you verify against the source’s real signing behavior.
Verify the HMAC Over the Raw Body
The single most common webhook verification bug is computing the signature over the wrong bytes. The sender signs the exact body it transmitted. If your framework parses the JSON, then you re-serialize it to verify, the whitespace and key order shift, the recomputed signature differs, and you either reject everything or — worse — relax the check until it passes. Verify over the raw request body, captured before any parsing.
import hmac, hashlib, time
def verify(raw_body: bytes, signature_header: str, timestamp_header: str,
secrets: list[bytes], tolerance_s: int = 300) -> bool:
# 1. freshness: reject stale timestamps
ts = int(timestamp_header)
if abs(time.time() - ts) > tolerance_s:
return False
# 2. authenticity: recompute over RAW bytes, constant-time compare
signed = f"{ts}.".encode() + raw_body
for secret in secrets: # support rotation: try each active secret
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, signature_header):
return True
return False
Two details carry weight. The comparison uses hmac.compare_digest, a constant-time function — an ordinary == short-circuits on the first differing byte and leaks, through timing, how much of a guessed signature was correct, which is enough to forge one byte at a time. And the function loops over a list of active secrets, which is what lets you rotate the signing secret without dropping in-flight deliveries: during the rotation window, both the old and new secret verify.
Timestamp Tolerance Buys Freshness, Not Uniqueness
The timestamp check above rejects requests outside a five-minute window. That blunts replay — a request captured yesterday is useless today — but it does not stop replay within the window. An attacker who captures a valid request can re-fire it as many times as they like for the next five minutes, and every copy passes both the signature and the timestamp check. Freshness and uniqueness are different guarantees, and conflating them is how replayable endpoints ship.
Uniqueness requires remembering what you’ve already accepted:
def accept(delivery_id: str) -> bool:
# SETNX with a TTL slightly longer than the timestamp tolerance
first_time = redis.set(f"wh:seen:{delivery_id}", "1", nx=True, ex=600)
return bool(first_time) # False == already processed, reject
A short-lived seen-ID store keyed on the delivery ID (most webhook sources provide one) makes a replayed request a no-op: the first delivery sets the key, every replay finds it already set and is rejected. The TTL only needs to outlive the timestamp tolerance, because anything older is already rejected on freshness grounds — so the store stays small. This is the same dedup reasoning behind idempotency keys for safe automation, applied at the front door.
Prompt: “I receive Stripe webhooks at this endpoint. Write verification middleware that recomputes the HMAC-SHA256 over the raw request body using a constant-time compare, enforces a 5-minute timestamp tolerance, supports verifying against two active secrets during rotation, and rejects replayed deliveries by ID. Produce a test matrix covering valid, forged, stale, and replayed requests.”
What it returns: middleware with raw-body verification, a rotation-aware secret loop, a delivery-ID dedup check, and a four-case test matrix. The matrix is the deliverable that turns “I think it’s secure” into assertions you can run.
Fail Closed, Log Loudly
A rejected request must never reach the automation handler, and the rejection should be visible. Return a clear status — 401 for a bad signature, 400 for a stale or malformed timestamp — and log forged, stale, and replayed requests distinctly, because a spike in any of them is a signal. A flood of forged requests means someone found your endpoint; a flood of replays means someone captured a valid one. Silent rejection hides the attack you most want to see.
Verify Against Reality, Not the Spec
Webhook signing schemes have quirks that documentation glosses over: which exact bytes are signed, whether the timestamp is concatenated or in a separate header, how the signature is encoded. Do not trust a from-memory implementation, including the model’s. Point the middleware at a staging receiver and replay real recorded deliveries from the source — valid ones should pass, and you should be able to tamper with one and watch it fail. Then deliberately replay a valid delivery twice and confirm the second is rejected.
The division of labor holds: the model drafts competent middleware and the test matrix quickly, but verification fails open by nature — a broken check keeps accepting requests while trusting forgeries — so the proof has to come from real deliveries, not assertions. For the design-side checklist see the webhook dedupe and replay-protection prompt, and the wider AI for Automation collection.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.