WebhooksGENERALPROTOCOL-SPECIFICFRAMEWORK-SPECIFIC

Webhook Signature Verification

Proving the request came from the provider — computed over the raw bytes, compared in constant time, bounded by a timestamp.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

How do I know this webhook actually came from the provider and not from anyone who guessed the URL?

The requirement

Our /webhooks/stripe endpoint marks orders as paid. It must be impossible for someone who finds the URL to mark their own order paid.

The obvious build

Put a long random string in the URL path so it is unguessable, or check a shared secret in a header. If the header matches, the request is genuine.

Why it breaks

A secret in the URL is logged by every proxy, CDN, load balancer and access log between the provider and your handler. It leaks the first time someone shares a log line.

How it breaks in production
  • A secret in the URL is logged by every proxy, CDN, load balancer and access log between the provider and your handler. It leaks the first time someone shares a log line.
  • A static shared secret in a header is a bearer token: anyone who sees one request can replay it forever, with any body they like.
  • Even with a correct HMAC, comparing digests with === leaks timing information, and comparing a digest against a value the attacker controls the length of leaks more.
  • The most common real failure is subtler: the framework parsed the JSON body, and the code re-serializes it to compute the HMAC. Key order, unicode escaping and number formatting all change, the digest does not match, and verification fails for legitimate requests — or, if someone "fixes" it by making verification optional on parse failure, it can be bypassed entirely.
  • Without a timestamp check, a captured valid delivery can be replayed at any point in the future, and it will verify perfectly, because it genuinely was signed by the provider (Replay Attacks).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The provider computes HMAC(secret, signed_payload) where signed_payload is a documented concatenation — commonly a timestamp, a separator, and the exact request body bytes — and sends the result in a header.
  • HMAC is a keyed hash: without the secret you cannot produce a valid digest for a body you chose, and you cannot recover the secret from digests you have seen (Hashing vs Encryption vs Encoding).
  • Verification means recomputing the same digest over the same bytes with the same secret and comparing. Same bytes is the load-bearing phrase: HMAC is defined over an octet string, and {"a":1,"b":2} and {"b":2,"a":1} are different octet strings that parse to the same object.
  • The timestamp is inside the signed payload precisely so that it cannot be altered. Rejecting deliveries whose timestamp is outside a tolerance window converts an unlimited replay window into a bounded one.
  • Comparison must be constant-time. A byte-by-byte comparison that returns on first mismatch takes measurably longer for a digest that shares a longer prefix, which is enough to forge one byte at a time given enough attempts.
  • Some providers sign with asymmetric keys instead: they sign with a private key, you verify with their published public key. Same discipline, no shared secret to store or rotate on your side (Digital Signatures).

The raw body is the message

HMAC is computed over bytes. The provider computed it over the bytes they sent. If you compute it over anything else — a parsed object re-serialized, a string decoded and re-encoded, a body a proxy recompressed — you are signing a different message and the comparison is meaningless.

What makes this the most common webhook bug is that it usually *works* at first. Simple test payloads survive a JSON round trip unchanged, so verification passes in development. Production payloads contain an accented name, a monetary value that serializes as 1.0 instead of 1, or a key order your serializer normalises. Verification starts failing for a subset of real traffic, and the subset looks random.

The dangerous repair is worse than the bug. Faced with intermittent verification failures, the tempting fix is to make failure non-fatal, or to fall back to an unverified path when the raw body is unavailable. Either turns an authenticated endpoint into an open one.

Computing the digest
Over the re-serialized object
app.use(express.json())
app.post('/webhooks/stripe', (req, res) => {
  const expected = hmacSha256(secret, JSON.stringify(req.body))
  if (expected !== req.header('stripe-signature')) return res.sendStatus(400)
  // ...
})
Over the bytes that arrived
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json', limit: '1mb' }),
  (req, res) => {
    const { t, v1 } = parseSigHeader(req.header('stripe-signature'))
    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400)
    const expected = hmacSha256(secret, Buffer.concat([Buffer.from(`${t}.`), req.body]))
    if (!crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'))) return res.sendStatus(400)
    // req.body is still raw bytes here; parse only now
  })

JSON.stringify of a parsed object is a different octet string from the one that was signed whenever key order, number formatting or unicode escaping differ. The right-hand version never materialises a second representation, checks the timestamp before spending a hash, and compares in constant time.

Constant-time comparison and the replay window

PROTOCOL-SPECIFICThe t=...,v1=... header shape and the timestamp.body construction follow Stripe's scheme. GitHub sends X-Hub-Signature-256 over the body alone with no timestamp, so replay must be bounded by delivery-id deduplication instead. Read the provider's spec; do not port this verbatim.

Two small details separate verification that works from verification that holds up. The first is comparing digests without leaking how far the comparison got. A naive comparison exits at the first differing byte, so a digest sharing more leading bytes takes longer — enough signal, over enough requests, to reconstruct a valid digest one byte at a time. Every crypto library ships a constant-time compare; use it, and compare fixed-length buffers so the length itself is not a side channel.

The second is the timestamp. Without it, a valid delivery captured anywhere — a debug log, a proxy trace, a shared HAR file — is a forgery with no expiry, because it really was signed by the provider. Including the timestamp inside the signed payload means it cannot be edited, and rejecting timestamps outside a tolerance window turns "forever" into "a few minutes".

The window is a real tradeoff rather than a setting to maximise. Too wide and replay stays cheap; too narrow and legitimate retries during a provider backlog — which can be hours behind — are rejected as stale. Provider documentation states a recommended tolerance for exactly this reason.

Verification, framework-free
1import hmac, hashlib, time
2
3TOLERANCE_SECONDS = 300
4
5def verify(raw_body: bytes, header: str, secrets: list[bytes]) -> bool:
6 parts = dict(p.split("=", 1) for p in header.split(","))
7 ts, sent = parts.get("t"), parts.get("v1")
8 if not ts or not sent:
9 return False
10
11 # Timestamp is inside the signed payload, so it cannot be edited
12 # without invalidating the digest. Check it first: it is free.
13 if abs(time.time() - int(ts)) > TOLERANCE_SECONDS:
14 return False
15
16 signed_payload = ts.encode() + b"." + raw_body # bytes, never str
17 sent_bytes = bytes.fromhex(sent)
18
19 # Accept either secret during rotation; compare_digest is constant-time
20 # and both operands are fixed-length digests.
21 return any(
22 hmac.compare_digest(
23 hmac.new(s, signed_payload, hashlib.sha256).digest(), sent_bytes
24 )
25 for s in secrets
26 )

Three things are deliberate: raw_body is bytes and is never decoded, the timestamp is checked before the hash is computed, and the secret list has two entries so a rotation is a config change rather than a coordinated deploy.

What verification does not give you

A verified signature is a narrow claim: these bytes were produced by someone holding the signing secret, recently. It is not a claim that this is the first time you have seen them, that the event refers to an object the request is entitled to touch, or that the state described is still current.

Teams routinely stop at verification and treat the handler body as trusted. It is trusted as to origin only. Every other check that would apply to an authenticated user request still applies here.

QuestionDoes signature verification answer it?What answers it
Did the provider send this?Yes — that is exactly what it provesHMAC over the raw body
Was it sent recently?Only if the timestamp is signed and checkedTimestamp tolerance window
Have I already processed this?NoDeduplication on the provider event id (Webhook Idempotency)
Is the payload well-formed?No — a valid signature over garbage still verifiesSchema validation after verification (Transport Validation)
Does this object belong to this tenant?NoAn explicit ownership check (Object-Level Authorization)
Is this the newest state of the object?NoA version or timestamp comparison, or re-fetching from the provider
Is the URL in the payload safe to fetch?NoDo not fetch it (SSRF — When the Backend Fetches a URL)

How to build it

Most important first.

  • Capture the raw body before any parsing middleware touches it, and verify against those bytes. In most frameworks this means registering a raw-body handler for the webhook route specifically, not globally.
  • Compare digests with a constant-time function from your platform's crypto library — crypto.timingSafeEqual, hmac.compare_digest. Never ==.
  • Reject deliveries whose signed timestamp is outside a tolerance window. Providers document a recommended window; it exists to bound replay, and shortening it too far breaks legitimate retries.
  • Support two active secrets simultaneously so a rotation does not require a synchronised deploy: accept if either verifies (The Secret Lifecycle).
  • Verify before parsing, before logging the body, before any database access. A failed signature should consume as little of your resources as possible.
  • Return 400 on verification failure, not 500 — you never want a retry of an unverifiable delivery, and 5xx invites one.

What can go wrong

Failure modes
  • Body-parser ordering: a global JSON middleware runs first, the raw stream is gone, and the handler silently verifies against re-serialized text. Works in the test suite where payloads are simple, fails on the first payload with a unicode character or a float.
  • A gzip or content-encoding transformation applied by a proxy between the provider and your process, changing the bytes you receive.
  • Trailing-newline and charset differences when the body is read as a string rather than as bytes.
  • Secret rotated on the provider side with a deploy still holding the old one — every delivery fails verification, and the provider retries all of them, and then dead-letters them.
  • The mitigation failing dangerously: a try/catch around verification that logs and continues. That is not verification, it is a comment.
  • Verification present but the endpoint also reachable through an unauthenticated internal path that skips middleware.
What can race
  • Secret rotation is a race between the provider switching keys and your deploy rolling out. Accepting both keys during the overlap is what removes it (Rolling Deployments).
  • A duplicate delivery verifies exactly as well as the original — verification says nothing about whether you have seen this event before (Webhook Idempotency).
Security
  • Without verification, the endpoint is an unauthenticated write to your most sensitive state. An attacker who knows the shape of a payment_intent.succeeded payload can mark any order paid.
  • The signing secret is a credential with the same blast radius as a database password. It belongs in the secret store, not in an environment variable committed to a chart (Secrets Are Not Configuration).
  • Timing-unsafe comparison is exploitable in principle and free to avoid. There is no scenario where === on a digest is the right call.
  • Without a timestamp window, a single captured delivery is a permanent forgery. The attacker does not need the secret; they need one request.
  • Verification proves origin, not authorization. A genuine customer.deleted event for a customer id that does not belong to the tenant in your URL is still an authorization decision you have to make (Object-Level Authorization).
Misreads
  • "We verify the signature" — over what bytes? The claim is only meaningful with an answer to that question.
  • "HMAC and a hash are the same thing." A plain SHA256(secret + body) is a real construction with real weaknesses; use HMAC, which your library already provides.
  • "The signature authenticates the user." It authenticates the *provider*. Every authorization decision about the objects named in the payload is still yours (Authentication vs Authorization).
  • "An unguessable URL is enough." URLs appear in logs, browser history, error reports and screenshots. They are identifiers, not credentials.

Operating it

How you see it in production
  • Emit a counter of verification outcomes: ok, bad_signature, stale_timestamp, missing_header. Each has a different cause and a different response.
  • Alert on any sustained bad_signature rate. Zero is the normal value; a step change usually means a rotation, and a slow trickle usually means someone found the URL.
  • Log the key id or secret version used to verify, so a rotation incident can be diagnosed without guessing.
  • Record the skew between the signed timestamp and your clock. Growing skew means either provider backlog or your own clock drift, and the second one silently breaks verification.
What changes at 10x and 100x
  • HMAC cost is proportional to payload size and negligible next to a database round trip at any realistic webhook volume — it is not a bottleneck worth optimising.
  • What does change with scale is the raw-body buffering: every delivery must be held in memory to be verified, so a body-size limit on the webhook route is a memory limit (Request Bodies and Streaming).
  • With many providers, each with its own scheme, header name and window, the per-provider verification logic multiplies. It is worth one small abstraction and no more.
What this costs
  • Raw-body capture fights every framework's convenience layer. The route ends up structured differently from the rest of the application, which looks inconsistent and is correct.
  • A tight timestamp window bounds replay and rejects legitimate late retries during a provider backlog. The window is a genuine security-versus-availability dial.
  • Dual-secret acceptance makes rotation safe and means a leaked old secret stays valid until you remove it. Rotation is not finished when the new key works; it is finished when the old one is deleted.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALHMAC over raw bytes, constant-time compare, timestamp window — the shape is the same everywhere.
  • PROTOCOL-SPECIFICThe signed payload construction is per-provider: some sign timestamp.body, some sign the body alone, some sign selected headers, some use asymmetric signatures with a published public key. Copying one provider's verification code to another provider is a real and common bug.
  • FRAMEWORK-SPECIFICWhere the raw body is available differs sharply: Express needs express.raw() mounted on the route before express.json(); FastAPI gives you await request.body() but a Pydantic-typed parameter has already consumed it; Rails exposes request.raw_post. The failure looks the same in all three and the fix does not.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Cryptography — why HMAC rather than a raw hash of secret-plus-message, and what constant-time comparison protects against in practice.