How to Secure a Webhook Endpoint (HMAC + Replay Protection)
An open webhook accepts anything. Secure it like Stripe and GitHub: verify an HMAC signature over the raw body, reject old timestamps to block replays, and compare signatures in constant time. Here's how.
Why webhooks need this
A webhook URL is public, so anyone can POST to it. Without verification, an attacker can forge events (fake a payment, trigger actions). The standard defense (Stripe, GitHub, Shopify all do it): the sender signs each request with a shared secret; you verify the signature before trusting the payload.
The signature header
Senders include a header like:
X-Webhook-Signature: t=1700000000,v1=5257a869e7 ...
t is the timestamp; v1 is HMAC_SHA256(secret, "<t>.<raw_body>"). You recompute it and
compare.
Verify it
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
# 1. Replay protection: reject stamps outside the tolerance window
if abs(time.time() - int(t)) > tolerance:
return False
# 2. Recompute the HMAC over "<t>.<raw_body>"
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# 3. Constant-time compare (no early-exit timing leak)
return hmac.compare_digest(expected, sig)
The three things that make it secure
- Sign the raw body, not the parsed JSON. Re-serializing changes bytes (key order, spacing) and breaks the signature - verify against the exact bytes received.
- Timestamp + tolerance = replay protection. Without checking
t, an attacker can resend a valid captured request forever. Reject anything older than ~5 minutes. - Constant-time comparison.
hmac.compare_digest(not==) so an attacker can't learn the signature byte-by-byte from response timing.
Operational notes
- Return 2xx fast, process async. Acknowledge quickly so the sender doesn't retry; do the real work in a queue. Combine with idempotency (dedupe on the event id) since senders retry and deliver at-least-once.
- Rotate the secret if it leaks; support two valid secrets briefly during rotation.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Computing + verifying an HMAC signature over the raw body
- Adding timestamp tolerance for replay protection
- Using constant-time comparison (hmac.compare_digest)
FAQ
How do I verify a webhook signature?
Recompute HMAC-SHA256 over the signed payload (usually timestamp.raw_body) with the shared secret and compare it to the signature header using a constant-time comparison. Reject if they don't match.
How do I stop webhook replay attacks?
Include and verify a timestamp in the signed payload, and reject requests whose timestamp is outside a tolerance window (e.g. older than 5 minutes), so a captured valid request can't be resent indefinitely.
Why verify the raw body instead of parsed JSON?
Parsing and re-serializing JSON changes the bytes (key order, whitespace), which breaks the HMAC. You must compute the signature over the exact raw bytes the sender signed.
What is HMAC authentication for a webhook?
HMAC webhook authentication has the sender sign each request body with a shared secret and send the signature in a header. The receiver recomputes the HMAC over the raw body and compares - if they match, the payload is authentic and untampered.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →