Lion Cipher Lab
Secure a webhook receiver with HMAC signatures (verify, constant-time, replay-proof)
- AppSec / DevSecOps
- Intermediate
- ~75 min
- Free · no account required
Why it matters
A webhook is how one system tells another that something happened, in real time: Stripe POSTs your server when a payment succeeds, GitHub POSTs you when someone pushes, a messaging platform POSTs you when a message arrives. To receive them you publish a public URL — and that is the problem. A URL that accepts POSTed events, with no verification, is an unauthenticated command channel: anyone who discovers or guesses it can POST a forged 'payment.succeeded' and trick your app into shipping goods, crediting an account, or triggering a deploy. The fix the whole industry converged on is a message…
What you'll learn
- explaining what a webhook is — a URL you publish so someone else's server can POST you events — and why an unauthenticated one is an open door for spoofed and forged events
- writing a tiny Flask webhook receiver in Python and watching the naive version accept ANY POST, including a completely made-up event
- adding HMAC-SHA256 signature verification the way GitHub and Stripe do it: the sender signs the request body with a shared secret and sends the hex digest in a header, and the receiver recomputes it
- verifying the signature over the RAW request bytes BEFORE parsing the JSON — and explaining why parse-then-reserialize is a real, common bug that breaks otherwise-correct verification
- using hmac.compare_digest for a constant-time comparison and explaining the timing side-channel that makes `==` unsafe for comparing secrets and digests
- adding replay protection with a signed timestamp and a tolerance window so a captured valid request cannot be resent later, and naming idempotency keys as the complementary defence
- attacking your own receiver: proving that a forged request (no secret), a tampered body (changed after signing), and a replayed old request are each rejected with 401
- making the judgment call — HMAC vs mTLS vs asymmetric signatures (Ed25519), why the secret must be per-endpoint and rotated, and why logging or leaking the secret undoes the whole control
Steps in this lab
- What a webhook is, and why a naive one is an open door
- Set up Python and Flask
- Build the NAIVE receiver and forge an event against it
- How GitHub and Stripe sign a webhook — the plan
- Build the HARDENED receiver: verify over the RAW body, before parsing
- Why constant-time: the timing side-channel that makes == unsafe
- The signer: how a real sender builds the request
- Attack it: forged, tampered, and replayed requests all get 401
- The judgment: HMAC vs the alternatives, and how the secret really fails