"This coupon can be used once" is a business rule, and a common way to implement it has the same shape everywhere: read the current state, decide, then write the new state. Each of the three steps is correct. The rule can still be broken, because requests do not arrive one at a time.
The pattern, as PortSwigger describes it
The Web Security Academy page on race conditions defines them as problems that arise "when websites process requests concurrently without adequate safeguards", and calls the time in which a collision can happen the race window. Its best-known type is the limit overrun, illustrated with a discount code:
- The application checks whether the code has already been used.
- It applies the discount to the order total.
- It records in the database that the code has been used.
If one user submits the code twice almost simultaneously, both requests can pass the first check before the third step runs. In James Kettle's 2023 research "Smashing the state machine: the true potential of web race conditions", the short-lived state a request passes through is called a sub-state, and the central claim is that "with race conditions, everything is multi-step". The same research reports that such windows are often around a millisecond, and that network jitter had historically hidden them, which is why a rule that "has never been broken in production" is weak evidence that it holds.
A measurable demonstration
The example below uses Node.js with the built-in node:sqlite module (it prints an experimental-feature warning on Node.js 24) and a coupon that has exactly one use left. The pause in the middle stands in for the database round trip and the other work a real handler does between the check and the write.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE coupons (code TEXT PRIMARY KEY, uses_left INTEGER NOT NULL);
CREATE TABLE redemptions (code TEXT, user_id TEXT, PRIMARY KEY (code, user_id));
INSERT INTO coupons VALUES ('WELCOME10', 1);`);
const roundTrip = () => new Promise(r => setImmediate(r));
// Version 1: check, then update.
async function redeemNaive() {
const row = db.prepare('SELECT uses_left FROM coupons WHERE code = ?').get('WELCOME10');
if (row.uses_left < 1) return false;
await roundTrip();
db.prepare('UPDATE coupons SET uses_left = uses_left - 1 WHERE code = ?').run('WELCOME10');
return true;
}
// Version 2: one conditional statement; the result says who won.
async function redeemAtomic() {
await roundTrip();
const r = db.prepare('UPDATE coupons SET uses_left = uses_left - 1 WHERE code = ? AND uses_left > 0').run('WELCOME10');
return r.changes === 1;
}
// Version 3: "once per user" enforced by a key, not by an earlier SELECT.
async function redeemOncePerUser(user) {
await roundTrip();
const r = db.prepare('INSERT OR IGNORE INTO redemptions (code, user_id) VALUES (?, ?)').run('WELCOME10', user);
return r.changes === 1;
}
Twenty parallel calls to each version, starting from one remaining use, gave:
| Version | Parallel requests | Granted |
uses_left afterwards |
|---|---|---|---|
| 1. Check, then update | 20 | 20 | -19 |
1. Same code, with CHECK (uses_left >= 0) on the column |
20 | 1 | 0 |
2. Conditional UPDATE, changes === 1
|
20 | 1 | 0 |
3. Primary key (code, user_id), same user |
20 | 1 | not involved |
| 3. Primary key, 20 different users | 20 | 20 | not involved |
Three things stand out.
- The unprotected version grants all twenty. Every request saw one use left, because every read happened before any write.
-
A constraint turns the same code into a safe one, but by accident. With a
CHECKon the column, nineteen of the updates fail with a constraint error (the test counts those as refusals) and the result looks right. PortSwigger lists datastore integrity features such as uniqueness constraints among its recommendations, as defense in depth. A backstop is valuable; the rule should still be expressed in the statement itself, so that losing a race is an expected result and not an exception. - The last two rows are different rules. The primary key limits one user to one redemption; it says nothing about how many uses the coupon has in total. A real service needs both, in one transaction, so that a rejected second step undoes the first.
The example does not use multiple connections or threads: the interleaving comes from await, which is enough to show the logic but not the behaviour of a particular database engine under load. That needs a test against the real database.
What the database does with the conditional statement
Version 2 works because the check and the write are the same operation. How concurrent updates to the same row are resolved depends on the isolation level. The PostgreSQL documentation describes it for the default Read Committed level: a second updater "will wait for the first updating transaction to commit or roll back", and if the first one commits, the second re-evaluates the WHERE clause against the new row version. In the example, the second request therefore finds uses_left > 0 false and changes nothing.
At Repeatable Read the second transaction is rolled back with could not serialize access due to concurrent update, and the documentation says applications using this level "must be prepared to retry transactions due to serialization failures". Serializable can fail for read/write dependencies as well; such failures return SQLSTATE 40001. In short: moving the rule into one statement is the first step, and the retry policy is the second.
PortSwigger's prevention guidance, in one list
- Keep sensitive endpoints' state changes atomic, using the datastore's own concurrency features.
- Avoid mixing data from different storage places, and do not use one storage layer to protect another.
- Use integrity features such as uniqueness constraints as defense in depth.
- Be careful with session handling: updating session variables one at a time can open a window, and ORMs can hide transactions.
- Do not assume per-session locking protects you. The page notes that PHP's native session handler processes one request per session at a time, which can hide a vulnerability that appears when requests use different sessions.
A test list for your own service
Each row is a burst of simultaneous requests an authorised tester sends to their own staging environment, with the expected result.
| Rule | Burst | Expected |
|---|---|---|
| A single-use coupon | 20 simultaneous redemptions of the same code | Exactly 1 success; the rest get a clear "already used" response |
| A balance of 100 | 10 simultaneous withdrawals of 50 | At most 2 succeed; the balance never goes negative |
| One referral bonus per account | Simultaneous claims by the same account | 1 bonus |
| A limit that depends on a counter | Simultaneous requests at the limit | Never more than the limit, counted from the database |
| The same burst, each request on a different session | Same as above | Same result as with one session |
Count outcomes in the database afterwards, not only in the HTTP responses: a response can say "failed" while a partial write is left behind.
Practise the review
Breachloom has a chapter, One remaining coupon use must have one winner, with a hands-on exercise, Concurrent coupon redemption, and a matching pair for balances, Parallel withdrawals must share one view of spendable funds and Concurrent withdrawal simulation. For limits at the API layer, API Rate Limits Are Not a Complete Defence Against Business Abuse covers why a rate limit and a business-rule limit are different controls. The chapters and the free IDOR case need no account; the full set of challenges is part of a paid plan.
Which rule in your own service starts with a SELECT and ends with an UPDATE? Writing a 20-request burst for that one rule takes minutes, and its result is more convincing than a code review.
Sources
- PortSwigger Web Security Academy, "Race conditions" (definition, limit overrun, session-based locking, "How to prevent race condition vulnerabilities"). https://portswigger.net/web-security/race-conditions
- James Kettle, "Smashing the state machine: the true potential of web race conditions", PortSwigger Research, 9 August 2023. https://portswigger.net/research/smashing-the-state-machine
- PostgreSQL documentation, "Transaction Isolation": Read Committed (13.2.1), Repeatable Read (13.2.2), Serializable (13.2.3). https://www.postgresql.org/docs/current/transaction-iso.html
- Node.js documentation,
node:sqlite.
Top comments (2)
The most important lesson here is that a check passing doesn't guarantee the condition will still be true when the write happens.
I like the use of atomic updates because they make the database enforce the rule at the moment of the change.
Testing with 20 parallel requests is also a good reminder that concurrency bugs often stay invisible until real traffic exposes them.
Agreed. The check and the write have to be one statement, or the database has to refuse the second write; anything in between is a window. A 20-request burst is the cheapest way to see that window before real traffic does.