Writing Realtime & messaging

A POST is not your event

A 200 on a public webhook URL proves someone POSTed. It does not prove the body is yours — HMAC is the gate; a secret in env is not.

Bytes land on a public webhook URL. The JSON looks like our event. The handler returns 200 and enqueues. The secret was in env the whole time. That body is whoever reached the path.

The wrong model is: health checks and a shared secret in env mean the URL is safe enough. Signature can wait.

That is not a missing secret. It was loaded. It is accept without authenticate. Anyone who can POST to the URL can send a body.

Invariant: side effects run only after a timing-safe HMAC match over the raw body. Missing or bad signature → accepted: false, reason: "bad_signature", queue unchanged. The env secret is input to the compare, not a substitute.

Why a 200 is not a signature

A 200 means the process took the POST. Reachable is not authenticated. /health on the same host proves the process is alive. It does not prove the bytes are ours.

A secret in env is furniture until something compares it. Loading it is configuration. Skip the verify and the secret still sits there, unused, while the attacker event joins the queue.

The tempting delay is ship the listener, add HMAC when we have a real vendor payload. The listener is already public. Every enqueue before the compare is a side effect that looks like a vendor event.

Those are two different meanings of 200. Mixing them is how an unsigned POST becomes “the vendor sent this.”

Lab: secret-in-env is not the gate

I ran node lab/webhook-hmac-verify.mjs in this repo on 13 August 2026. In-process ingest. No HTTP.

I did not hit a live vendor for this note. No captured production payload. The claims stop at the gate and the queue.

CaseWhat we needResult
valid HMACeffect after verifyaccepted, queued 1
missing signaturereject, queue emptybad_signature, queued 0
bad signaturereject, queue emptybad_signature, queued 0
skip verify (secret loaded)interesting failureattacker accepted, secretWasLoaded true
{
  "valid": { "accepted": true, "queued": 1 },
  "missing": { "accepted": false, "reason": "bad_signature", "queued": 0 },
  "bad": { "accepted": false, "reason": "bad_signature", "queued": 0 },
  "skipVerify": { "accepted": true, "queued": 1, "secretWasLoaded": true },
  "allPassed": true
}

The interesting failure is not that missing and bad signatures reject. It is that skip-verify accepts the attacker body while the secret is loaded. Env had the input. Nothing used it as a gate.

Verify before effect

The compare is the gate. Parse and enqueue after it matches. Not before.

HMAC the raw body — the bytes that arrived. Parse JSON after verify. If you parse first and sign the object you rebuilt, the bytes often will not match what the sender signed.

A missing header fails closed before timingSafeEqual. It is not an empty string you still feed the compare. Missing and bad both return bad_signature here. Queue stays empty.

This lab uses HMAC-SHA256 with a header of the form sha256=<hex>.

const expected = sign(secret, body);
const ok = signature && signaturesMatch(signature, expected);
if (!ok) return { accepted: false, reason: "bad_signature" };
queue.push(JSON.parse(body));

Loading the secret is not verifying.

Decision boundary

Rejected: signature can wait; health as proof the webhook is safe; treating a secret in env as the gate.

This note is the wrong tool for a queue you already control. Your own workers, your own jobs — you do not HMAC a push you issued.

Outbound, a 200 on send is still only accept. That is a sibling problem; HMAC inbound does not replace the delivery status machine. A signed event can still be a retry, land out of order, or repeat a status you already applied.

This does not buy retry, a DLQ, idempotent consumers, or vendor App Review. Authenticate the body. Then the status machine still has to run.

Accept is not authenticate. The signature check is what makes the event yours. If the queue moved because a stranger POSTed, the bug is the missing gate.

A public notebook

Notes from real delivery: racey quotas, sync when a device is offline, messaging APIs, and the gap between a clean local demo and production.

Not a product catalog, a tutorial syllabus, or a course funnel. If a post names a tool, I used it. If it describes a failure, it happened.

About Contact