Webhook signatures, explained
Last updated:
A webhook signature is a short string a sending service attaches to each delivery — an HMAC computed over the exact bytes of the request with a secret only the sender and the receiver share. It exists so the receiving server can answer two questions before it acts: did this really come from the service it claims to come from, and did the body arrive exactly as it was sent? This article explains how that verification actually works: what the HMAC covers, why it must be checked against the raw body rather than a parsed one, why the comparison has to run in constant time, how timestamps bound replay attacks, and why every provider formats the header differently. If you want to look at a real delivery first — headers, signature and raw body together — point a sender at the Webhook Tester and inspect what arrives.
Why a secret URL is not authentication
The oldest webhook protection is an unguessable URL: a long random path segment that only the sending service knows. It feels like a password, but it behaves like an address, and addresses leak. The URL is written into access logs, proxy logs, monitoring dashboards, screenshots in bug reports and the configuration pages of the sending service itself, where any support engineer — or anyone who compromises a single one of those systems — can read it. A password can be hashed at rest and rotated per user; a URL is copied verbatim everywhere it is used, and every copy is a standing invitation.
The deeper problem is what the endpoint does with a request once it arrives. Webhook handlers mark invoices paid, provision accounts, grant licences, delete data. Those are actions worth forging, and a forged request costs an attacker one HTTP POST from anywhere on the internet. TLS does not help here, because TLS authenticates your server to whoever connects; it says nothing about who the connecting party is. A signature moves the trust from “knows where to knock” to “knows a secret that never appears in the request itself”, which is a categorically stronger claim.
What an HMAC signature actually proves
The sender and receiver share a secret, generated when the endpoint is registered. For each delivery, the sender computes an HMAC — a keyed hash — over a defined set of bytes: typically the raw request body, often prefixed with a timestamp, sometimes with a delivery ID. The result travels in a header. The receiver recomputes the HMAC over the bytes it received, using its own copy of the secret, and compares the two values. A match proves two things at once: the sender possessed the secret, and the covered bytes are bit-for-bit the bytes the sender signed. Change one character of the amount field and the recomputed value diverges completely.
Two limits are worth stating plainly. First, a signature is not encryption: the body crosses the network readable by anyone positioned to see it — keeping it private is what TLS is for — and the signature only vouches for origin and integrity. Second, a plain hash of the body would prove nothing, because anyone can hash anything. The secret is what turns a hash into a message authentication code. The hash generator on this site computes bare hashes, which are fine as checksums — but a checksum that anyone can recompute authenticates no one.
Verify the raw body, byte for byte
The most common cause of “verification fails on genuine deliveries” is not an attacker — it is the receiver’s own framework. The signature was computed over the body exactly as transmitted: this key order, this whitespace, these escaped characters, this number formatting. Most web frameworks helpfully parse the JSON body into an object before your handler runs, and if you re-serialise that object in order to verify, you get the same data in different bytes: keys reordered, spaces added, 1e3 rendered as 1000, unicode escaped or unescaped. Semantically identical, cryptographically different — and verification fails on a delivery that was perfectly genuine. Stripe’s documentation states the rule flatly: signature verification requires the raw body, and the framework must not manipulate it.
The operational consequence is ordering: capture the raw request body before anything parses it, verify the signature against those bytes, and only then parse and process. Proxies and middleware that rewrite bodies — re-encoding, normalising, pretty-printing — break signatures just as surely as a parser does, which is why debugging starts with looking at the request exactly as it arrived. That is the view a receiving tool gives you: when a delivery lands in the Webhook Tester, what you inspect is the untouched request, headers and body as transmitted, against which a signature can be checked honestly.
Constant-time comparison, and why the clock is part of the signature
Comparing the two HMAC values looks like the trivial step, and it hides a subtle leak. An ordinary string comparison stops at the first mismatching character, so it returns faster when a guess is wrong early and slower when a longer prefix happens to be right. Over many requests, that timing difference lets an attacker measure their way toward a valid signature one character at a time, without ever learning the secret. A constant-time comparison always examines the full length and takes the same time whether the values differ in the first character or the last. Platform documentation is blunt about this — GitHub’s, for example, warns explicitly against a plain equality operator and points to constant-time functions such as crypto.timingSafeEqual. Use the library function; do not write the comparison loop yourself.
Freshness is the third property, and a signature alone cannot give it to you: a captured genuine delivery, replayed verbatim, carries a perfectly valid signature. That is why many schemes fold a timestamp into the signed bytes, and why receivers reject deliveries whose timestamp sits outside a tolerance window — five minutes by default in Stripe’s scheme, per its documentation. The window is a deliberate compromise: wide enough to absorb clock skew and slow queues, narrow enough that a captured request quickly goes stale. Inside the window, defence falls to idempotency: record the delivery or event ID of everything you have processed, and drop repeats — which you need anyway, because honest senders retry, and a retry is a replay with good intentions.
Why every provider’s header looks different
The mechanics are standard; the packaging never converged. Stripe sends a Stripe-Signature header of the form t=<timestamp>,v1=<signature>, and may include several v1 values at once. GitHub sends X-Hub-Signature-256, whose value is the algorithm-prefixed string sha256=<hex digest> — no timestamp, so a GitHub delivery carries no built-in expiry, and replay defence rests entirely on deduping delivery IDs. The Standard Webhooks specification is an attempt to settle the question: separate headers for delivery ID, timestamp and signature, with the signature computed over all three plus the body, so new providers stop inventing incompatible variants. The differences are historical accidents, not different security models — in every scheme the invariant is the same: a shared secret, a defined set of covered bytes, a constant-time comparison.
The multiple v1 values in Stripe’s format exist for the operation every receiver eventually faces: rotating the secret. Done naively — change the secret, deploy, hope — rotation breaks everything in flight, because retries and queued deliveries signed with the old secret suddenly fail. The workable pattern is a parallel window: during the overlap, signatures under both the old and the new secret are accepted, you confirm deliveries verify under the new one, and only then is the old secret retired. The same window pattern appears in the JWT world of key IDs and overlapping keys, and the failure mode when teams skip it is identical, as the JWT mistakes article describes. A secret that cannot be rotated without downtime will not be rotated even when it should be — including the day it leaks — which is why the unglamorous parallel window is part of the protocol, not an afterthought.