Skip to content

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

How we use AISend a correction

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
Why these scores

Claim ledger

Ranked by verification strength, evidence, and original report placement.

  1. [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.

    ReportedSupportedView cited source
  2. [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.

    ReportedSupportedView cited source
  3. [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).

    ReportedSupportedView cited source

Sources

1 independent publisher whose own reporting we read for this story.

  1. dev.to

    1 article · August 22, 2026

    How I Stop Webhook Replay Attacks in Node.js & PostgreSQL

Share your take

Let Clarity write the post for you.

Signed-in readers get a short post drafted on this story in the register they choose — narrative, analytical, or a direct position — editable to the last word before it goes anywhere. The share buttons at the top of this story work without an account.

Topics and entities

Follow any of these and your For You feed starts watching them — no settings page required.

Topics

Loading related stories