THIS EXPLANATION
THE ROOM
CDA·02 Computing, Data & AI 7 MIN · 8 STATIONS

A notification you did not ask for

A Socratic walk-through of a notification you did not ask for — reasoned out one step at a time, not lectured.

abcdefgh
a

The question we started with

THE QUESTION #

When something arrives unrequested claiming to come from a service you trust, how can you tell?

Almost every trusted exchange on the web is one you initiated. You dial out, the certificate proves the other end is who it claims, and the answer arrives inside the channel you opened. Trust flows from having made the call.

A webhook reverses that. Your server sits waiting on a public URL, and a request arrives saying "payment succeeded, order 8812, from us". You did not call anyone. You cannot check a certificate on an inbound caller the way a browser checks a server's. And anyone on the internet can send that same POST. So what evidence, exactly, could distinguish the real notification from a fabricated one — and why do the obvious answers fail?

b

Reasoning it through

REASONING #

Work through the obvious answers first, because their failures are what motivate the real design.

Check the source IP address? Some providers publish ranges. But infrastructure moves, ranges change without warning, shared egress can admit unrelated tenants, and behind a proxy the address you see is the proxy's. A coarse filter, never proof.

Keep the URL secret? This is the one worth killing properly. URLs are not secrets: they leak into logs, proxies, error reports, browser history, and screenshots, and a secret that is transmitted on every single request is a secret with many chances to escape. Obscurity here buys a little time, not security.

Just look at the payload — it names the order and the amount? But an attacker who has ever seen a legitimate payload can construct one that names anything. The content is not evidence of its own origin.

So what is evidence of origin? The thing every one of the above lacks: proof that the sender holds something only the sender could hold. And since a webhook is a one-shot fire-and-forget message with no round trip for a challenge, that proof must be carried inside the message itself.

Which gives the standard construction. The two parties share a secret established out of band, in the provider's dashboard. The sender computes a keyed hash — an HMAC, usually with SHA-256 — over the request body using that secret, and sends the result in a header. You recompute the same hash with your copy of the secret and compare. A match means the sender knew the secret, and because the hash covers the body, it also means the body has not been altered. Origin and integrity from one check.

Now push on it, because two gaps remain and both have bitten real systems.

First, the hash covers the body — but which body? If your framework parses the JSON and you then re-serialise it to hash, you may have changed whitespace or key order, and the hash will not match. Worse, some implementations verify against a reconstruction rather than the original bytes, which lets a crafted payload verify while the parsed object differs from what was signed. So the rule is strict: capture and hash the raw bytes exactly as received, before any parsing.

Second, and more subtly: a signature proves authenticity but says nothing about when. A valid message captured once can be replayed forever. Suppose a genuine "refund issued" webhook is intercepted or recovered from a log and re-sent a hundred times — every copy verifies perfectly, because every copy is genuine. Two fixes compose here. Include a timestamp in the signed material, so it cannot be edited, and reject anything older than a short tolerance; Stripe's scheme signs timestamp.body and defaults to a five-minute window. Then, separately, make handling idempotent by recording each event identifier and ignoring one already processed — which you need anyway, since at-least-once delivery means honest duplicates will arrive regardless.

One implementation detail that looks like pedantry and is not: compare the signatures with a constant-time function. A comparison that returns early on the first differing byte leaks, through its timing, how much of a guess was correct — enough, over many attempts, to reconstruct a valid signature byte by byte.

c

The analogy

THE ANALOGY #
THE FIGURE

Think of a letter arriving with a wax seal pressed from a signet ring. You cannot verify the courier, and the envelope's return address is whatever the sender wrote. What you can verify is the impression: it could only have been made by someone holding the ring, and breaking the seal to alter the contents destroys it. Origin and integrity, carried by the message rather than by the channel.

WHERE IT BREAKS DOWN

a wax seal is physically consumed when opened, so a sealed letter cannot be resealed and delivered twice, whereas a digital signature is perfectly copyable — the identical message replays forever and verifies every time, which is exactly why timestamps and idempotency keys have to be bolted on and are not implied by the signature at all.

d

Clarifying the model

THE MODEL #

Three refinements.

First, note precisely what is being authenticated. The signature authenticates the message, not the connection and not the sender's identity in any broader sense. It proves someone with the secret produced these bytes. If that secret is shared with a third party, sits in a repository, or is reused across environments, the proof degrades accordingly — and rotating it is therefore a routine operation, not an incident response.

Second, HMAC is symmetric: both sides hold the same key, so your copy of the secret can also forge messages. For most webhook relationships that is acceptable, since the risk lies with the recipient. Where it is not, the alternatives are asymmetric signatures — the sender signs with a private key you never possess and you verify with a public one — or mutual TLS, which authenticates the connection rather than the message. Some providers use one of these; the reasoning that selects between them is about who could plausibly want to forge what.

Third, a pattern that sidesteps the whole question: treat the webhook as a hint rather than as data. Do not act on the payload; act on its identifier by calling the provider's API to fetch the current state over a connection you opened. That restores the direction of trust to the familiar one and is the most robust option available, at the cost of an extra round trip. It is genuinely the right answer for high-value events, and overkill for routine ones.

e

A picture of it

THE PICTURE #
A notification you did not ask for
A notification you did not ask for These are four independent conditions, not four steps -- an inbound delivery can satisfy the first two and still be an attack, which is the point. The top pair comes from the signature itself; the bottom pair is work your handler must do that no signature performs for it. Drop any one and the request should be refused. {"generator":"[email protected]","source":"../Socrates/.diagram-cache/_src/a-notification-you-did-not-ask-for.md","sourceIndex":1,"sourceLine":4,"sourceHash":"fdced095519f0487d75f945c137969fd1a093132de1ee27f9dfdee3b98cc03a4","diagramType":"requirement","layoutVariant":"source","repairedDuplicateIds":[],"motion":"entrance-with-reduced-motion-fallback","presentation":"editorial","attempt":1,"viewBox":{"x":0,"y":0,"width":1808,"height":538},"qa":{"passed":true,"findings":[]}} satisfies satisfies verifies verifies <<Requirement>> authenticity ID: 1 Text: the sender proved it holds the shared secret Risk: High Verification: Test <<Requirement>> integrity ID: 2 Text: the signed bytes are the bytes we parsed Risk: High Verification: Test <<Requirement>> freshness ID: 3 Text: the signed timestamp is inside the tolerance Risk: Medium Verification: Test <<Requirement>> exactly_once ID: 4 Text: this event identifier was not handled before Risk: Medium Verification: Test <<Element>> inbound_request Type: webhook delivery <<Element>> handler Type: our endpoint

How to readThese are four independent conditions, not four steps — an inbound delivery can satisfy the first two and still be an attack, which is the point. The top pair comes from the signature itself; the bottom pair is work your handler must do that no signature performs for it. Drop any one and the request should be refused.

f

What became clearer

WHAT CLEARED #
WHAT CLEARED

Because a webhook arrives on a channel you did not open, no property of the connection can vouch for it — so the evidence has to travel inside the message. A keyed hash over the raw body supplies origin and integrity together, and nothing more: freshness and single-handling are separate properties requiring a signed timestamp and an idempotency record. The recurring failures are not cryptographic but structural — hashing a re-serialised body, comparing signatures non-constant-time, and forgetting that a genuine message stays genuine forever.

g

Where to go next

ONWARD #
  • Asymmetric webhook signing, and when it is worth not holding a key that could forge.
  • Delivery semantics: retries, at-least-once guarantees, and designing handlers to be idempotent.
  • Secret rotation with overlapping keys, so verification survives the changeover.
h

Key terms

TERMS #
TermWhat it means
Webhookan unsolicited HTTP request a provider sends to your URL to notify you of an event.
HMACa keyed hash that proves the sender knew a shared secret and that the covered bytes are unaltered.
Raw bodythe exact bytes received, before parsing or re-serialisation, which is what must be hashed.
Replay attackre-sending a captured, genuinely valid message to cause its effect again.
Idempotency keya stable event identifier recorded on first handling so repeats are ignored.
Constant-time comparisonbyte comparison that always takes the same time, so timing reveals nothing about a mismatch.

Every term the collection defines is gathered in the glossary.

Nearby on the shelf

4