Race Conditions and TOCTOU (CWE-362, CWE-367): How Parallel Requests Break Checks and How to Fix Them
Code that reads a value, checks it and then writes back lets two requests arriving at the same moment both pass the check, so a gift card is redeemed twice or a balance goes negative. How the check-then-act window works, why application-level checks cannot close it, and how a single conditional UPDATE ... RETURNING, row locks and database constraints make the operation atomic, with a Python and PostgreSQL example.
Plain English Explainer (ELI5)
A cinema has two cashiers and one seat left in row F. Both look at the seating chart at the same second, both see seat F7 free, and both sell it. Each cashier checked before selling; the problem is that the check and the sale were two separate moments, and the other cashier acted in between. The fix is a single ticket clip that both cashiers must pull from: whoever pulls the last F7 ticket gets it, and the other finds the clip empty. Checking and taking become one action.
Core Concepts & Subsystem Terms
- TOCTOU (time-of-check to time-of-use)
- The state that was checked can change before it is used. In web code the check is usually a
SELECTand the use is a laterUPDATE, with other requests running in between. - Race window and parallel requests
- The window is often a few milliseconds, but attackers can line up dozens of requests to arrive together. PortSwigger's 2023 research showed that sending the final bytes of many HTTP/2 requests in one packet lands them within about a millisecond of each other.
- Atomic conditional update
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...checks and changes the row in one statement. Only one caller gets a row back; the others get none.- Row locks under Read Committed
- In PostgreSQL a second
UPDATEon the same row waits for the first transaction, then re-evaluates itsWHEREclause against the committed row. That re-check is what makes the conditional update safe without raising the isolation level. - Constraints and idempotency keys
UNIQUEindexes andCHECK (balance_cents >= 0)make a lost race fail loudly instead of double-spending. AnIdempotency-Keystored under a unique index lets clients retry payments without repeating them.
Step-by-Step Attack Flow
The attacker holds one valid gift card
They have a single card worth a fixed amount and an account to credit.
They send many redemptions at once
Twenty requests with the same code arrive within a millisecond. Two parallel requests using the test code TEST-0001 illustrate the case.
Every request passes the check
Each SELECT runs before any request has marked the card as redeemed, so all of them see redeemed = false.
The balance is credited many times
Each request adds the card's value before the flag is set. In 2015 Egor Homakov showed that parallel transfers between Starbucks gift cards could create balance out of nothing.
Source Code: Flaw vs. Secure Implementation
# gift_cards.py: check and use are separate statements
from .db import pool
# every parallel request can see redeemed = false before any of them writes
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn:
card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
if card is None or card[2]:
raise ValueError("invalid or used card")
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
return card[1]
# gift_cards.py: check and use happen in one statement
from .db import pool
# only one caller gets a row back; the rest wait for the lock and see NOT redeemed fail
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn, conn.transaction():
card = conn.execute(
"UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
"WHERE code = %s AND NOT redeemed RETURNING balance_cents",
(user_id, code),
).fetchone()
if card is None:
raise ValueError("invalid or used card")
# credit in the same transaction, so both changes commit or neither does
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
Engineering & System Hardening Checklist
- Replace read-check-write sequences on money, stock, coupons and quotas with one conditional
UPDATE ... WHERE ... RETURNING, or withSELECT ... FOR UPDATEinside a transaction. - Back one-time actions with database constraints (
UNIQUE,CHECK (balance_cents >= 0)) so a lost race fails instead of succeeding twice. - Accept an
Idempotency-Keyon payment and redemption endpoints and store it under a unique index. - Do not rely on in-process locks or flags when more than one worker or server handles requests.
- Add a concurrency test that fires 20 parallel redemptions of one test card and asserts that exactly one succeeds.