Here is an example of Python code that is vulnerable to a time-of-check to time-of-use race condition:
🥺 Vulnerable Code
@app.post("/api/redeem")
def redeem():
code = request.json["code"]
user_id = session["user_id"]
coupon = db.execute(
"SELECT id, used, amount FROM coupons WHERE code = ?", (code,)
).fetchone()
if not coupon or coupon["used"]:
return {"error": "Invalid coupon"}, 400
# Gap between the check above and the write below: parallel requests
# all read used = 0 before any of them sets it to 1
db.execute("UPDATE coupons SET used = 1 WHERE id = ?", (coupon["id"],))
db.execute(
"UPDATE accounts SET balance = balance + ? WHERE user_id = ?",
(coupon["amount"], user_id),
)
db.commit()
return {"credited": coupon["amount"]}Fire twenty copies of this request at the same time with Turbo Intruder or a small asyncio script and every one of them reads used = 0 before the first UPDATE lands. The single-use coupon pays out twenty times. The same pattern shows up in withdrawals, invite codes, vote counters, and anywhere a read decides whether a write is allowed.
😎 Secure Code
Here is a version of the same code that is secured against a time-of-check to time-of-use race condition:
@app.post("/api/redeem")
def redeem():
code = request.json["code"]
user_id = session["user_id"]
with db.transaction():
# One atomic statement picks the winner: only the first UPDATE matches
row = db.execute(
"UPDATE coupons SET used = 1, used_by = ? "
"WHERE code = ? AND used = 0 RETURNING amount",
(user_id, code),
).fetchone()
if row is None:
return {"error": "Invalid or already used coupon"}, 400
db.execute(
"UPDATE accounts SET balance = balance + ? WHERE user_id = ?",
(row["amount"], user_id),
)
return {"credited": row["amount"]}The condition moved into the UPDATE itself, so the database decides the winner atomically and the losers get zero affected rows. SELECT ... FOR UPDATE inside the same transaction works too. Back it up with a unique constraint on (coupon_id, user_id), an idempotency key on the endpoint, and a rate limit so the attempt is visible in your logs.