Community Python Snippet
Webhook Signature Verifier With Replay Protection
How I verify Stripe-style webhook signatures and stop someone from re-POSTing yesterday's `invoice.paid`. Stdlib HMAC, a tolerance window, and an idempotency cache that lives in any Redis-shaped store.
Webhook Signature Verifier With Replay Protection
How I verify Stripe-style webhook signatures and stop someone from re-POSTing yesterday's `invoice.paid`. Stdlib HMAC, a tolerance window, and an idempotency cache that lives in any Redis-shaped store.
By @averyperry
November 28, 2025
·
Updated May 20, 2026
594 views
10
4.2 (10)
The signed string is <timestamp>.<raw_body>, not the parsed JSON, because re-serializing JSON is the classic way to break a verifier (key order, whitespace, float precision). A 5-minute tolerance is the value Stripe defaults to and is what I keep in my own services because anything tighter starts catching legitimate clock drift between cloud regions. The tolerance check happens before the HMAC check on purpose: an attacker who can spam your endpoint forces you to do real crypto work for free without the timestamp gate. hmac.compare_digest is again the only correct way to compare the two hex strings.
Signature verification stops a stranger from forging a webhook, but it does nothing about the legitimate sender retrying. A network timeout on the provider's side means your endpoint will see the same evt_42 twice and, if your handler is not idempotent, charges or status flips can double up. The pattern I ship is setnx(event_id, ttl) against any key-value store: the first writer wins, the second sees False and returns 200 immediately so the provider stops retrying. The TTL is set to the provider's longest retry window plus a margin (Stripe is 3 days; I use 7 to be safe).
The order of operations matters more than any individual check: header parse, timestamp tolerance, HMAC compare, and only then json.loads on the raw body. JSON parsing is not free and accepts a wide variety of inputs, so doing it on a forged 100MB body before signature verification is a denial-of-service vector. Returning 200 for a known-duplicate event is the part most homegrown handlers get wrong; if you return 4xx, the provider will keep retrying on its schedule for hours. In production I swap InMemoryStore for Redis with SET key value NX EX ttl and the rest of the code is unchanged.
