BuildNot yet confirmed elsewhere1 publisher3 min readPublished
A unique index is not a duplicate check: the race inside a webhook idempotency middleware
A Node and Postgres webhook guide gets the storage choice right and the ordering wrong: SELECT then INSERT leaves a race that answers a concurrent duplicate with a 500.
The Engineer · Build desk
What happened
- A dev.to walkthrough argues that HMAC signature validation stops forgery but does nothing about a valid, signed payload being resent.
- It keeps processed event IDs in a Postgres column with a UNIQUE constraint rather than Redis, on the grounds that a cache flush or restart loses the state.
- The middleware rejects timestamps more than 300 seconds off, compares the HMAC with crypto.timingSafeEqual, then SELECTs the event_id and returns 409 if it is present.
- The route handler inserts the processed_webhooks row first and runs the crediting logic afterwards.
- The proposed test is one valid request, an immediate identical repeat expecting 409, and a ten-minute-old timestamp expecting 401.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Insert-first with no enclosing transaction marks an event done when the credit fails, and the 409 on every later attempt guarantees nobody retries the money that never moved.
- cost Every honest delivery pays two round trips to settle a question the unique index settles inside the INSERT, and the SELECT buys nothing the constraint does not already enforce.
- constraint The 300-second window caps how late a sender may retry: an attempt still carrying its originally signed timestamp is refused as stale, so an operator reads a lost event as a blocked attack.
- contradiction The prose promises the event row is written inside the transaction block while the posted handler opens no transaction, so what a reader copies is the ordering without the guarantee.
The unique index in that schema is doing real work. The SELECT above it is not the thing doing it. Two deliveries of the same `event_id` arriving together both pass the 300-second freshness test, both pass the constant-time HMAC compare, both find no row, and both call `next()` [4]. Postgres then serialises them at the unique index and the second INSERT raises a violation [3], which the author describes as Postgres handling the lock and dropping the duplicate [14]. It does drop it, and the balance is not credited twice, because the INSERT sits above the business logic [5]. But the loser's exception falls into the handler's generic catch and comes back as HTTP 500 [5], and 500 is the status that tells a sender to come back. The 409 the design wants only shows up on a later attempt, once the winner's row is committed and visible to the lookup [4].
The case made for Postgres over Redis is durability: a cache flush or a container restart during a spike loses processed-event state [2]. That is a configuration argument, and configuration can answer it. The argument that tuning cannot answer is atomicity. When the dedupe key lives in the same database as the balance, one transaction makes the row and the credit succeed or fail as a unit. Two stores mean two commits and a gap between them, and everything that happens in that gap presents later as either a double credit or a payment that evaporated.
Two smaller things in the schema. A UNIQUE column is already backed by an index, so the explicit `idx_processed_webhooks_event_id` is a second index on the same column, and every inserted row pays for both [3][10]. And nothing prunes the table [8], although the freshness check quietly bounds what has to be kept: no request more than 300 seconds out of date reaches the lookup at all [4], so a row older than five minutes can never be matched by a duplicate that got through the first gate [12]. Five minutes of retention is sufficient for the mechanism as written, which is a much smaller storage commitment than an append-only ledger of every event you have ever seen.
The verification script in the piece is sequential: send, get 200, send the same payload again, get 409, then backdate the timestamp and get 401 [7]. That ordering is why the race survives testing. The second request always meets a committed row. Two curl processes started at the same moment would have produced the 500 instead, and the fix is in the same paragraph as the bug: let the INSERT be the check, and translate its failure into the conflict response the sender already knows how to read.
What to watch
- Whether a revision handles the unique violation on the INSERT and answers 409 from it, which would let the SELECT go entirely.
- Whether the sending platform re-signs each retry with a current timestamp; if it does not, the five-minute window will drop legitimate late deliveries.
- Any retention job for processed_webhooks, and whether it prunes to the freshness window or keeps every event forever.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence58
- Adoption
- Insufficient
- Hype gap+52
- Incentives24
- Confidence66
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The dev.to writeup states that validating signatures is step one but will not protect against a replay attack in which a valid, signed payload gets resent.
- [2]
The author writes that processed event IDs could be stored in Redis, but a cache flush or a container restart during a high-traffic spike loses state, and recommends PostgreSQL with a unique constraint instead.
- [3]
The schema creates processed_webhooks with event_id VARCHAR(255) UNIQUE NOT NULL and then adds a separate index, idx_processed_webhooks_event_id, on processed_webhooks(event_id).
- [4]
The middleware runs three checks before execution: it rejects requests whose timestamp differs from now by more than MAX_AGE_SECONDS = 300 with a 401, compares an HMAC-SHA256 signature using crypto.timingSafeEqual and returns 403 on mismatch, then runs SELECT id FROM processed_webhooks WHERE event_id = $1 and returns 409 'Event already processed' if a row exists.
- [5]
In the route handler the INSERT into processed_webhooks runs before the business logic (a console.log crediting the user), and any thrown error is caught and answered with HTTP 500 'Processing error'.
- [6]
The article's prose instructs the reader to log the event inside Postgres as part of the transaction block, but the posted handler contains no BEGIN or COMMIT.
- [7]
The suggested local test is: a valid request returning 200 OK, an immediate identical repeat returning 409 Conflict, and an x-timestamp set ten minutes in the past returning 401.
- [8]
The processed_webhooks table has a created_at TIMESTAMPTZ defaulting to CURRENT_TIMESTAMP, and the article shows no pruning or retention policy for the table.
- [9]
With the INSERT committed before the business logic and no enclosing transaction, a failure in the business logic leaves the event recorded as processed and the effect never applied; every later delivery then matches the SELECT and is answered 409, so no retry repairs it. That is at-most-once, not exactly-once.
- [10]
A UNIQUE column constraint is backed by an index, so processed_webhooks carries two indexes on event_id and pays two index writes for every inserted row.
- [11]
Each accepted delivery costs two database round trips, one SELECT in the middleware and one INSERT in the handler, for a decision the unique index can adjudicate inside the INSERT alone.
- [12]
Since no request whose timestamp is more than 300 seconds from now reaches the database check, no duplicate that passes the freshness test can ever match a processed_webhooks row older than five minutes.
- [13]
A retry that carries the timestamp it was originally signed with and arrives more than five minutes later is answered 401 stale, before the event_id is ever looked up, so a lost delivery is reported as a blocked replay.
- [14]
The author states that if two identical requests hit the backend at the exact same millisecond, Postgres handles the lock and drops the duplicate.
- [15]
Because the duplicate check is a SELECT in middleware and the INSERT happens later in the handler, two concurrent deliveries of one event_id can both read no row and both proceed; the unique index rejects the second INSERT, and that exception is caught by the handler's generic catch and returned as 500 rather than 409.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toHow I Stop Webhook Replay Attacks in Node.js & PostgreSQL
1 article · August 22, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.