Safeguard
Application Security

An Idempotency Key That Only Checks Prior Receipt Protects Nothing

A client retries a timed-out payment request. Both copies arrive close together, both pass the check for whether the key exists, because neither has finished processing yet, and both charge the card.

Shadab Khan
Engineering
6 min read

A payment request times out on the client side. The user, seeing no confirmation, clicks pay again. Your server received both requests, fully processed both, and charged the card twice, because nothing told it the second request was a retry of the first rather than a second, distinct purchase.

Idempotency keys solve this, and they are one of the more misapplied patterns in API design, because implementing the field without implementing the guarantee it implies gives you the appearance of safety with none of the substance.

This post is what an idempotency key actually has to do. For whoever added the header without reading how it needs to work underneath.

What the key is a promise about

A client generates a unique value and sends it with a request that has a side effect. The contract: if the server has already processed a request with that key, it returns the original result rather than processing again, regardless of how many times the request arrives.

That is a promise about outcome, not about request receipt. A common half-implementation checks whether a request with that key was received before, which does not protect against the case that actually matters: two copies of the same request arriving close together, both passing the "have I seen this key" check because neither has finished processing yet, and both proceeding to charge the card.

The concurrency case is where it actually breaks

Sequential retries, request one, timeout, then request two seconds later, are the easy case and most implementations handle them correctly. Concurrent duplicates, both arriving within milliseconds because a client retried aggressively or a mobile network delivered a request twice, are what most implementations get wrong, because the check-then-process sequence has a gap.

Request A: check key exists? no. -> begin processing
Request B: check key exists? no. (A hasn't finished) -> begin processing
Both charge the card.

The fix needs an atomic claim on the key, not a check followed by a separate write:

INSERT INTO idempotency_keys (key, status, created_at)
VALUES ($1, 'processing', now())
ON CONFLICT (key) DO NOTHING
RETURNING key;

If the insert returns no row, another request already claimed that key. The correct behaviour then is not to process again, but to wait for the original to finish and return its result, or to return a "still processing" response the client can poll, rather than silently proceeding as if the key were new.

The key has to be scoped to the operation, not just unique

A key that is merely unique protects nothing if it is not also bound to the specific request it was meant to deduplicate. Store, alongside the key, a hash or fingerprint of the request body, and compare it on reuse.

Without this, a client bug that reuses the same key for two genuinely different requests, a different amount, a different recipient, gets the first request's result silently returned for the second, which is a correctness failure disguised as a safety feature. Reject a reused key whose request body does not match what was originally stored, rather than either processing it again or returning a mismatched result.

Expiry is a real decision, not a cleanup detail

Keys need a lifetime, and the choice trades two failure modes against each other directly.

Too short, and a legitimate retry after the window has closed is treated as a new request, reintroducing the double-charge the key existed to prevent. Too long, and the table grows without bound, and in some designs an old key becomes eligible for reuse by an unrelated later request if your client-side key generation is not sufficiently unique over time.

A day is a reasonable default for most payment and order-creation flows, matched to how long a client might plausibly retry after an ambiguous failure. State the window explicitly in your API documentation, because a client integrating against your API needs to know how long their own retry logic can trust it.

What to return on replay

The original response, byte for byte where practical, including the original status code. A client retrying a request expects the same acknowledgement it would have gotten the first time, and returning something different, a changed status, a different body shape, on the replay path is a source of confusing client-side bugs that only appear during retries, which makes them rare and hard to reproduce.

Where this needs to live, and where it does not

Anything that spends money, sends an irreversible communication, or creates a record that should exist exactly once: payments, order creation, sending an email or SMS, provisioning a resource. These are exactly the operations where a duplicate has a real cost, and they are exactly the operations most likely to be retried, because clients correctly treat them as important enough to retry on ambiguous failure.

Operations that are already naturally idempotent do not need this machinery. Setting a field to a specific value, deleting a resource by identifier, anything where doing it twice produces the same end state as doing it once. Adding idempotency key infrastructure there is complexity without a corresponding risk it prevents.

Client responsibility, stated plainly

The key must be generated once per logical operation and reused across retries of that same operation, not regenerated on each retry attempt, which defeats the entire mechanism. A client library generating a fresh key inside its retry loop has implemented exactly the bug the feature exists to prevent, and this is common enough in hand-rolled retry logic that it is worth calling out explicitly in your integration documentation with an example of the wrong way to do it.

Check yours

Send two requests with the same idempotency key, concurrently, using a tool that fires them at the same instant rather than sequentially:

key=$(uuidgen)
for i in 1 2; do
  curl -s -X POST https://api.example.com/charges \
    -H "Idempotency-Key: $key" \
    -d '{"amount": 1000}' &
done
wait

Then check whether one charge was created or two. If two, your implementation has the check-then-process gap regardless of what the API documentation claims about idempotency.

The concession

Full idempotency infrastructure, atomic claims, request fingerprinting, an expiry policy, a documented replay contract, is real engineering effort, and for an internal tool with no concurrent retry pattern and low consequence for a duplicate, a simpler best-effort deduplication may be entirely adequate. Not every endpoint needs the complete version.

The line is consequence, the same as almost everywhere else in this list: if a duplicate costs money, sends something irreversible, or creates a record a downstream system treats as authoritative, build the full version. Everything else can be simpler.

The implication

An idempotency key that only checks for prior receipt, rather than atomically claiming the operation, provides exactly the confidence a real one provides and exactly none of the protection, which is the worst combination available, because it looks solved.

Run the concurrent test above against your own payment or order-creation endpoint today. It takes two minutes, and it tells you whether the safety net you believe you have is actually there.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.