Skip to content

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

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.

One signed delivery, five ways of checking it A grid of 5 rows by 2 columns. One signed delivery, five ways of checking it check result HMAC over raw body valid delivery accepted HMAC over json.dumps(model) valid delivery REJECTED model param, then await request.body() original 61 bytes; accepted amount changed in transit 401 bad signature same delivery replayed 10 minutes later 400 stale timestamp FastAPI 0.142, Python 3.14; the sender's JSON had an extra space and its own key order.

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.

Order of checks for an incoming webhook A flow of 5 stages. Order of checks for an incoming webhook raw body size-limited timestamp within 300 s HMAC, compare_digest over timestamp.body dedupe event id, SET NX enqueue + 2xx process later Cheap checks first; nothing is parsed or trusted before the signature passes.

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.

What does this webhook handler need? A decision on What is the risk with 4 outcomes. What does this webhook handler need? What is the risk? forged deliveries HMAC over raw body + compare_digest json.dumps(model) fails replays and provider retries timestamp window + event-id dedupe 400 at 10 min huge bodies on an open endpoint Content-Length + streaming limit 413 slow processing, provider timeouts durable enqueue, ack fast no lost events Verify bytes, bound time and size, then hand off.

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.