FlawOpen
● CWE-362 · CWE-367

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 SELECT and the use is a later UPDATE, 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 UPDATE on the same row waits for the first transaction, then re-evaluates its WHERE clause against the committed row. That re-check is what makes the conditional update safe without raising the isolation level.
Constraints and idempotency keys
UNIQUE indexes and CHECK (balance_cents >= 0) make a lost race fail loudly instead of double-spending. An Idempotency-Key stored under a unique index lets clients retry payments without repeating them.

Step-by-Step Attack Flow

Step 1

The attacker holds one valid gift card

They have a single card worth a fixed amount and an account to credit.

Step 2

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.

Step 3

Every request passes the check

Each SELECT runs before any request has marked the card as redeemed, so all of them see redeemed = false.

Step 4

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

UNPATCHED FLAW
# 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]
HARDENED SECURE PATCH
# 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

Sources

← Browse the full security directory All vulnerability guides →