How to Verify a Stripe Webhook Signature (HMAC)
A Stripe webhook endpoint that trusts any incoming POST is an open door - anyone who knows the URL can fake a payment.succeeded event. Stripe signs every delivery with HMAC-SHA256; verifying that signature is the one line of defense between your upgrade flow and fraud.
Why unsigned webhooks are dangerous
Stripe sends events to a public HTTPS URL you configure. Without signature verification, your endpoint has no way to tell a real Stripe delivery from a forged request. An attacker posts:
curl -X POST https://example.com/webhooks/stripe \
-H 'Content-Type: application/json' \
-d '{"type": "payment.succeeded", "id": "evt_fake"}'
If the handler acts on the body alone, the user gets upgraded without ever paying. Stripe signs every delivery to prevent exactly this.
The Stripe-Signature header format
Stripe sends a header of the form:
Stripe-Signature: t=1700000000,v1=<hex_hmac>
t is a Unix timestamp. v1 is HMAC-SHA256 of the string
"{t}.{raw_body}" keyed with your webhook secret. There can be multiple
v1= entries during secret rotation - a valid signature from any of them
should be accepted.
Implementing verification with the Python stdlib
Use hmac, hashlib, and time - no Stripe SDK needed. This is exactly
what the SDK does internally.
import os, time, hmac, hashlib, json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
TOLERANCE = 300 # 5 minutes, same as Stripe's default
def _verify(body: bytes, header: str) -> None:
t = None
v1s = []
for part in header.split(","):
part = part.strip()
if part.startswith("t="):
t = int(part[2:])
elif part.startswith("v1="):
v1s.append(part[3:])
if t is None or not v1s:
raise HTTPException(400, "malformed Stripe-Signature")
if abs(time.time() - t) > TOLERANCE:
raise HTTPException(400, "timestamp outside tolerance window")
# sign the raw bytes - NOT the parsed JSON
signed_payload = f"{t}.{body.decode()}".encode()
expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, v1) for v1 in v1s):
raise HTTPException(400, "signature mismatch")
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
body = await request.body() # raw bytes before JSON parse
_verify(body, request.headers.get("stripe-signature", ""))
event = json.loads(body)
# ... handle event
return {"received": True}
The four rules that matter
- Sign the raw bytes.
await request.body()gives you the exact bytes Stripe signed.await request.json()round-trips through a parser and can change whitespace - the signature will not match. - Use
hmac.compare_digest, not==. String equality short-circuits on the first mismatching byte, leaking timing information.compare_digestruns in constant time regardless of where the mismatch is. - Enforce the timestamp tolerance. Replay attacks reuse a captured, legitimately signed payload. Rejecting deliveries older than 300 seconds makes a captured payload useless within minutes.
- Accept multiple v1 entries. Stripe rotates secrets with an overlap
window; a request signed with the old key is still valid during the
transition. Accept the request if any
v1=value matches.
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
- Parsing the Stripe-Signature header and extracting the timestamp and v1 values
- Computing HMAC-SHA256 over the raw request body and comparing with hmac.compare_digest
- Rejecting replayed deliveries using a 300-second timestamp tolerance window
FAQ
How does Stripe webhook signature verification work?
Stripe computes HMAC-SHA256 over the string '{timestamp}.{raw_body}' using your webhook secret, then sends the result in the Stripe-Signature header as 't=<ts>,v1=<hex_hmac>'. Your server recomputes the same HMAC and compares in constant time.
Why must I use hmac.compare_digest instead of == to compare signatures?
String equality (==) short-circuits on the first mismatching character, which leaks timing information that an attacker can use to guess a valid signature byte by byte. hmac.compare_digest always runs in constant time regardless of where the strings differ.
What happens if I verify the JSON-parsed body instead of the raw bytes?
The HMAC will not match. Stripe signs the exact bytes it sent over the wire. Parsing the body into JSON and re-serializing it can alter whitespace and key ordering, producing different bytes and a different HMAC.
How do you verify a Stripe webhook signature?
Read the Stripe-Signature header, compute HMAC-SHA256 over the timestamp and the raw request body with your webhook signing secret, and compare it to the header signature in constant time. Reject the event if it does not match or the timestamp is stale.
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 →