Idempotency is a security property
Limit-overrun races, the single-packet attack and why "check then act" is a vulnerability. A developer's guide to idempotency keys, atomic updates and database constraints — written by someone who also exploits them.
Building transactional backends taught me a lesson that carried straight into security testing: anything that should happen once is an attack surface if it can happen twice. Redeem a coupon, withdraw a balance, accept an invite, claim a reward — if two requests arrive at the same moment, does your code do it twice?
The bug: check-then-act
def redeem(user, code):
coupon = db.get_coupon(code)
if coupon.used: # 1. check
raise AlreadyUsed()
apply_discount(user, coupon) # 2. act
coupon.used = True # 3. update
db.save(coupon)
Between steps 1 and 3 there's a window. Send twenty requests at once and several may all pass the check before any of them writes used = True. This is a limit-overrun race condition, a type of time-of-check to time-of-use (TOCTOU) bug.
Why it got much easier to exploit
Race windows are often a few milliseconds, and network jitter used to make them hard to hit remotely. In 2023 James Kettle of PortSwigger presented "Smashing the state machine: the true potential of web race conditions" at Black Hat USA and DEF CON. It introduced the single-packet attack: send 20–30 HTTP/2 requests with the final byte of each held back, then release all the final bytes in a single TCP packet. The server receives the requests effectively at the same instant, removing jitter from the equation.
Burp Repeater's "send group in parallel" and the Turbo Intruder extension implement it, so testing for race conditions is now a routine part of my web assessments.
How I test
- Identify actions with limits: one-time codes, balances, quotas, invitations, 2FA attempts, rate limits.
- Send the same request 20+ times in a single packet; compare the outcome against a single request.
- Look for multi-endpoint races too: for example, confirming an email change while a password reset is in flight, or adding an item to a cart while checkout is processing.
- Watch for sub-states: an object that is briefly "created but not yet locked" is a classic hidden state.
How I fix it as a developer
1. Make the database enforce the rule. Constraints don't race.
CREATE UNIQUE INDEX one_redemption_per_user ON redemptions (coupon_id, user_id);
2. Use atomic conditional updates instead of read-modify-write.
UPDATE coupons SET used = true
WHERE code = $1 AND used = false
RETURNING id; -- zero rows back = someone else won the race
UPDATE accounts SET balance = balance - $1
WHERE id = $2 AND balance >= $1;
3. Lock when you must read, then decide. SELECT … FOR UPDATE inside a transaction serialises competing requests on that row. Keep the transaction short.
4. Use idempotency keys for client retries. Payment APIs popularised the pattern of an Idempotency-Key header: the client sends a unique key per logical operation; the server stores the key with the result and, on a repeat, returns the stored result instead of executing again. The key must be inserted atomically (a unique constraint again) before the side effect, or you've just moved the race.
INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, 'processing')
ON CONFLICT (key) DO NOTHING; -- 0 rows inserted → duplicate, return stored response
5. Make downstream consumers idempotent. Message queues deliver at least once. A consumer that processes "charge customer" twice on redelivery is the same bug at a different layer — dedupe on an event ID.
The takeaway
Race conditions sit exactly where software engineering and security meet: the fix is ordinary good engineering (constraints, atomic writes, idempotency), and the exploit is now a one-click feature in standard tools. If an operation must happen at most once, prove it with a concurrency test — fire 50 parallel requests in CI and assert one success.
References
- PortSwigger Research — Smashing the state machine: the true potential of web race conditions
- PortSwigger Research — The single-packet attack: making remote race-conditions 'local'
- PortSwigger Web Security Academy — Race conditions
- PostgreSQL Docs — Explicit Locking