Build1 publisher3 min readPublished
Webhook post covers HMAC signatures and idempotency keys, but breaks off before replay protection is addressed
A dev.to walkthrough of async proof pipelines gets HMAC over raw bytes and storage-layer idempotency right, and its own SQLite schema shows where a signed but stale completion event still slips through.
The Engineer · Build desk

What happened
- A dev.to post on async proof pipelines puts the hard problems in the gap between submitted and confirmed, arguing the webhook is the difficult part rather than the hashing or the anchoring.
- Its verification recipe is HMAC-SHA256 over the raw request body, checked in a Flask handler that reads the X-Signature-256 header, aborts 401 on mismatch, and returns 204 once the event is processed.
- Two named failure modes: comparing with == short-circuits on the first bad byte and leaks timing, and verifying against re-serialized JSON causes false rejections from key order and whitespace.
- Idempotency is pushed down to the store, where event_id as PRIMARY KEY plus INSERT OR IGNORE resolves two racing deliveries into one success and one silent no-op instead of a check-then-set race.
- The post promises replay protection in its title, but the supplied text ends inside the sender's Retry-After handling before any replay window or timestamp tolerance is shown.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Signing the raw bytes means nothing between sender and handler may touch the body, which rules out any gateway or middleware that re-serialises JSON or normalises whitespace on the way in.
- decision Choosing Redis SETNX with a TTL over a permanent primary-key row sets how long a captured delivery stays rejected, which turns that TTL from a cache setting into a security parameter.
- cost Exactly-once handling costs one durable row per event, and pruning the table by age quietly hands back the replay rejection those rows were providing.
- exposure Because the completion event drives a status update, an unverified handler puts the downstream state machine and everything reading it within reach of anyone who finds the URL.
HMAC-SHA256 over the raw body answers exactly one question: did something holding the shared secret produce these bytes [4]. Freshness is out of scope. The same signed body verifies on the tenth delivery as well as it did on the first, and ten is not a hypothetical number, because the sender cannot tell a timeout from a dropped delivery and keeps trying [8].
The freshness check therefore has to come from somewhere else, and in the code as published it comes from the idempotency store. The table declares `event_id TEXT PRIMARY KEY` and `received_at REAL NOT NULL`, `mark_processed` inserts `time.time()`, and `already_processed` selects on `event_id` alone [10]. The timestamp is written on every event and read by nothing else in the code. It is a comment with a storage bill.
That seam is where a window would go: compare a sender timestamp inside the signed body against a tolerance, and keep the row at least as long as the tolerance. That is exactly where the two stores the post treats as interchangeable [12] diverge. A primary-key row that is never deleted rejects a replayed delivery forever. A Redis `SETNX` key with a TTL rejects it until the TTL expires, after which a captured body is new again. Whatever number goes in that TTL is the replay window, named or not.
The other decision buried in the example is ordering. `process_event` returns early if the event is known, then marks it, then applies the status update [13]. Marking first means a crash between those two lines loses the update while the event stays permanently recorded as done: at-most-once. Applying first and marking after gives at-least-once, with duplicates landing on `apply_status_update`. Which you want depends on whether that function is idempotent on its own, and the published ordering picks at-most-once on your behalf.
The database-level race guarantee [11] also extends exactly as far as the store extends. The example opens a module-level connection to a local file, `webhook_events.db` [18]. Several processes on one host sharing that file are covered by SQLite's own locking. Three application hosts behind a load balancer have three files and no guarantee, so that pattern transfers only if the store is shared: one Redis, one Postgres row, one anything.
On the sending side the defaults are worth pricing before you copy them. Five attempts at a five second request timeout is 25 seconds of network wait per delivery before a single backoff sleep is counted [15][17], and a 429 carrying `Retry-After` extends it further [15]. If your own handler's p99 sits anywhere near five seconds, that puts the sender's timeout, rather than your own throughput, at the center of what you are designing against.
What to watch
- Whether the published pattern grows a timestamp tolerance, and whether the signed body carries a sender timestamp for it to check at all.
- Whether the processed_events table gets a documented retention policy, given that the shown code inserts rows and deletes none.
- Whether receivers start publishing their expected verification bytes, so senders know a gateway that reformats JSON will break signatures.