Verifying Webhook Signatures in Async Handlers¶
Webhooks are requests from another system that your service must trust: a payment provider reporting a successful charge, a code host reporting a push. Providers sign each delivery with a shared secret — almost always HMAC-SHA256 over the request body, often with a timestamp — and the receiving handler's job is to verify that signature exactly as the provider computed it. Async frameworks make one part of that easy to get wrong. Measured on Python 3.14 with FastAPI 0.142: verifying over the raw request bytes accepted a correctly signed payload; verifying over the same payload after parsing it into a Pydantic model and re-serializing it rejected the identical, valid delivery, because the bytes differed. await request.body() still returned the raw bytes after FastAPI had parsed the model. A tampered amount was rejected with 401, a ten-minute-old replay with 400. HMAC itself cost 1.4 µs for a 1 KB body, 41 µs for 100 KB and 401 µs for 1 MB, so it belongs on the event loop. Comparing digests with == took 21.9 ns when the first byte differed and 23.3 ns when the last did; hmac.compare_digest took 44 ns either way. This guide verifies webhooks correctly.
Prerequisites¶
- Python 3.11+,
pip install fastapi uvicorn. - ASGI request handling, from ASGI Servers & Frameworks.
- Idempotent processing, from idempotency keys for safe async retries.
1. Verify over the exact bytes received¶
The signature covers the bytes the provider sent, so verification must use those bytes — not a parsed and re-serialized version:
import hashlib
import hmac
import json
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = b"whsec_..." # from the provider, kept in a secret store
app = FastAPI()
def expected_signature(body: bytes, timestamp: int) -> str:
return hmac.new(SECRET, f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
@app.post("/webhooks/payments")
async def payments(request: Request):
body = await request.body() # raw bytes, before any parsing
timestamp = int(request.headers["x-timestamp"])
if not hmac.compare_digest(expected_signature(body, timestamp), request.headers["x-signature"]):
raise HTTPException(401, "bad signature")
event = json.loads(body)
...
Measured: the raw-body handler accepted a valid delivery whose JSON had an extra space and an unusual key order. A handler that took a Pydantic Event parameter and verified json.dumps(event.model_dump()) rejected the same valid delivery — whitespace, key order, number formatting and Unicode escaping all change when JSON is re-serialized. In FastAPI, declaring both a model parameter and request: Request works: await request.body() returned the original 61 bytes after the model had been parsed, because Starlette caches the body. Exact signing formats differ by provider — some sign timestamp.body, some the body alone, some a header list — so implement theirs to the byte.
Verify: a recorded real delivery from the provider passes verification in a unit test, and the same delivery with one byte changed fails.
2. Compare signatures in constant time¶
Comparing the computed signature with the received one using == can leak, through timing, how many leading characters matched. Measured over two million comparisons of 64-character hex digests:
good = expected_signature(body, ts).encode()
good == early_mismatch # 21.87 ns (first byte differs)
good == late_mismatch # 23.29 ns (last byte differs)
hmac.compare_digest(good, early_mismatch) # 44.19 ns
hmac.compare_digest(good, late_mismatch) # 43.50 ns
The difference for == was small — about 1.4 ns across 63 bytes — but systematic, and timing attacks average over many attempts precisely to extract signals of that size. hmac.compare_digest took the same time regardless of where the inputs differed. The cost of the safe comparison, 44 ns, is irrelevant next to anything else in a request. Use it for every comparison of secrets: signatures, tokens, API keys.
Verify: grep finds no == or != comparisons involving signatures, tokens or keys in request handlers.
3. Reject stale timestamps and replayed deliveries¶
A valid signature proves the provider created the message, not that it is new. An attacker who captures one delivery can resend it. Bound the age, and remember what you have processed:
TOLERANCE = 300 # seconds; providers usually document theirs
@app.post("/webhooks/payments")
async def payments(request: Request):
body = await request.body()
timestamp = int(request.headers["x-timestamp"])
if abs(time.time() - timestamp) > TOLERANCE:
raise HTTPException(400, "stale timestamp") # checked before the HMAC is trusted
if not hmac.compare_digest(expected_signature(body, timestamp), request.headers["x-signature"]):
raise HTTPException(401, "bad signature")
event = json.loads(body)
if not await processed.add_if_absent(event["id"], ttl=2 * TOLERANCE):
return {"duplicate": True} # already handled: acknowledge, do nothing
await enqueue(event)
return {"ok": True}
Measured: the same signed delivery sent with a timestamp ten minutes old was rejected with 400. Because the timestamp is inside the signed content, an attacker cannot refresh it without the secret. Providers also retry deliveries legitimately, so deduplicating by event ID within the tolerance window makes processing idempotent for both cases; a Redis SET NX with an expiry is the usual store, as in caching with Redis asyncio clients.
Verify: sending the same valid delivery twice results in one processed event.
4. Keep verification on the loop, and bodies bounded¶
Measured HMAC-SHA256 costs: 1.4 µs for 1 KB, 41 µs for 100 KB, 401 µs for 1 MB. Webhook payloads are typically a few kilobytes, so verification on the event loop costs microseconds and needs no thread. What does need a bound is the body size, because the handler reads the whole body before it can verify anything:
MAX_WEBHOOK_BYTES = 1 * 2**20
@app.post("/webhooks/payments")
async def payments(request: Request):
if int(request.headers.get("content-length", 0)) > MAX_WEBHOOK_BYTES:
raise HTTPException(413, "payload too large")
chunks, size = [], 0
async for chunk in request.stream():
size += len(chunk)
if size > MAX_WEBHOOK_BYTES:
raise HTTPException(413, "payload too large")
chunks.append(chunk)
body = b"".join(chunks)
...
Checking Content-Length first rejects honest oversized requests immediately; counting while streaming catches chunked uploads that do not declare a length. Unauthenticated endpoints are exactly where an attacker would send a very large body, so the limit matters more here than on authenticated routes — the general technique is in limiting request body size in ASGI apps.
Verify: a 10 MB POST to the webhook endpoint is rejected with 413 without the handler buffering it all.
5. Acknowledge fast and process asynchronously¶
Providers expect a quick 2xx and retry on timeouts, often after only a few seconds. Do the verification and a durable enqueue in the request; do the work elsewhere:
async def enqueue(event: dict) -> None:
await outbox.insert(event_id=event["id"], payload=event) # durable, same transaction as dedupe
async def worker() -> None:
async for event in outbox.claim_batches():
await process_payment_event(event)
Processing inside the request couples the provider's retry behaviour to your downstream latency: a slow database makes the provider retry, the retry arrives while the first attempt is still running, and deduplication becomes the only thing standing between you and double processing. A durable job queue, as in building a durable job queue on Postgres with asyncio, decouples them. Never process in a fire-and-forget task: if the process restarts after acknowledging, the event is lost.
Verify: webhook responses complete in tens of milliseconds even while the downstream processor is slow or paused.
Verification¶
Webhook handling is trustworthy when:
- Signatures are verified over the raw body, in the provider's exact format, with
hmac.compare_digest. - Timestamps outside a tolerance window are rejected, and event IDs are deduplicated.
- Bodies are size-limited before they are buffered.
- Handlers acknowledge quickly after a durable enqueue, and processing happens elsewhere.
Diagnostic Hook: count webhook rejections by reason — bad signature, stale timestamp, too large, duplicate. A sudden rise in bad signatures after a deploy usually means the verification code started using parsed rather than raw bytes, or the secret changed; a steady trickle from unknown sources is probing, which the counts make visible.
Pitfalls & edge cases¶
- Re-serializing JSON before verifying. Measured: a valid delivery was rejected.
==for signatures. Measured: timing depended on where the strings differed.- No timestamp check. A captured delivery can be replayed indefinitely.
- Processing inside the request. Provider retries race the first attempt.
Frequently Asked Questions¶
How do I verify a webhook signature in FastAPI?
Read the raw bytes with await request.body(), compute the provider's HMAC over them (often timestamp.body) and compare with hmac.compare_digest. Verifying re-serialized JSON from a Pydantic model rejected a valid delivery in testing.
Can I read request.body() after FastAPI parses a Pydantic model?
Yes: in testing, await request.body() returned the original bytes after the model parameter had been parsed, because Starlette caches the body.
Why use hmac.compare_digest instead of ==?
== returns as soon as bytes differ, so its timing reveals how much of a guess matched; in testing it varied with the position of the difference while compare_digest took a constant 44 ns.
Is HMAC verification too slow for the event loop?
No: it cost about 1.4 µs for 1 KB and 401 µs for 1 MB in testing. Bound the body size instead.
Related¶
- Securing Async Services — up to the topic overview.
- Validating JWTs with cached JWKS in asyncio — the other common way to authenticate requests.
- Resilience, Cancellation & Error Handling — the section overview.