Safeguard
Security

A Practical Race Condition Example (and How to Fix It)

A race condition example is easiest to understand through a bank-balance check-then-act bug. Here is the anatomy, the exploit, and three ways to close it.

Marcus Chen
DevSecOps Engineer
6 min read

The clearest race condition example is a check-then-act sequence where two threads read the same value, both decide it is safe to proceed, and both act, producing a result neither would have allowed alone. This time-of-check to time-of-use (TOCTOU) gap is behind coupon double-redemption, negative account balances, duplicate order fulfillment, and a large share of privilege-escalation bugs. Once you can see the pattern in one example, you start finding it everywhere.

The canonical example: withdrawing money

Consider a withdrawal function. It reads the balance, checks it is sufficient, then debits:

def withdraw(account_id, amount):
    balance = db.get_balance(account_id)   # time of check
    if balance >= amount:
        new_balance = balance - amount
        db.set_balance(account_id, new_balance)  # time of use
        return "ok"
    return "insufficient funds"

Single-threaded, this is correct. Under concurrency it is not. Suppose the balance is 100 and two withdrawal requests for 100 arrive at nearly the same instant:

  1. Request A reads balance = 100.
  2. Request B reads balance = 100 (A has not written yet).
  3. A checks 100 >= 100, passes, writes balance = 0.
  4. B checks 100 >= 100, passes, writes balance = 0.

The account paid out 200 against a 100 balance. Nothing in the code is "wrong" line by line; the defect lives in the gap between the check and the act, where another actor changed the world.

Why this is a security problem, not just a bug

Attackers do not stumble into race conditions the way normal traffic does. They deliberately fire many concurrent requests to widen the window, a technique often called request racing. If a promo code is supposed to be redeemable once, an attacker sends fifty redemption requests in parallel and hopes several slip through before the "already used" flag is written. The same shape appears in:

  • Gift card and coupon double-spend.
  • Withdrawing or transferring more than a balance allows.
  • Bypassing rate limits or one-per-account restrictions.
  • File-system TOCTOU, where a path is checked for safety and then used, but swapped for a symlink in between.

The defect class is well catalogued as CWE-362 (concurrent execution using shared resource with improper synchronization) and CWE-367 (TOCTOU).

Fix 1: Serialize with a lock

The most direct fix is to make the check and the act atomic with respect to each other by holding a lock over the critical section:

def withdraw(account_id, amount):
    with account_lock(account_id):   # per-account lock
        balance = db.get_balance(account_id)
        if balance >= amount:
            db.set_balance(account_id, balance - amount)
            return "ok"
        return "insufficient funds"

Lock per account, not globally, or you serialize every user in the system behind one mutex and destroy throughput. The trade-off is real: locks add latency and, done carelessly, deadlock. But they close the window cleanly.

Fix 2: Push the decision into the database

A better answer for the bank example is to let the database enforce the invariant atomically. A conditional update does the check and the act in a single statement that the database serializes for you:

UPDATE accounts
SET balance = balance - :amount
WHERE account_id = :id AND balance >= :amount;

If the affected-row count is zero, the balance was insufficient and nothing changed. There is no gap for a second request to exploit, because the database evaluates the WHERE predicate and applies the write as one operation. Adding a CHECK (balance >= 0) constraint gives you a second line of defense at the schema level.

Fix 3: Make the operation idempotent

For workflows where the risk is duplicate execution rather than a shared counter, an idempotency key removes the race entirely. The client generates a unique key per logical operation and sends it with the request; the server records "I have processed this key" atomically:

INSERT INTO processed_requests (idempotency_key)
VALUES (:key)
ON CONFLICT (idempotency_key) DO NOTHING;

If the insert succeeds, this is the first time the operation ran, so proceed. If it conflicts, a concurrent or earlier request already handled it, so return the prior result. Payment APIs use this pattern precisely because retries and double-clicks are unavoidable.

Detecting race conditions before production

Race conditions are notoriously hard to catch because they are nondeterministic; a test passes ninety-nine times and fails on the hundredth. Some practices that help:

  • Write concurrency tests that fire the operation from many threads at once and assert the invariant afterward (final balance never negative, coupon used exactly once).
  • Use language and runtime race detectors where they exist, such as Go's -race flag or ThreadSanitizer for C and C++.
  • In code review, treat every read-then-conditional-write on shared state as suspect. If the value can change between the read and the write, ask what serializes them.
  • Dynamic testing against a running app can surface exploitable windows; the DAST scanner approach of firing concurrent requests at state-changing endpoints is well suited to flushing out request-racing bugs. Our AppSec fundamentals track covers where these fit in a testing program.

The unifying lesson from every race condition example is the same: if correctness depends on the world not changing between your check and your act, make that assumption true by construction, with a lock, an atomic database operation, or an idempotency key.

FAQ

What is a race condition in simple terms?

It is a bug where the outcome depends on the timing of concurrent operations. The classic example is two threads reading a balance of 100, both deciding a withdrawal of 100 is allowed, and both proceeding, so the account pays out 200 it did not have.

What is the difference between a race condition and a TOCTOU vulnerability?

TOCTOU (time-of-check to time-of-use) is a specific and common race condition: the gap between validating a resource and using it. All TOCTOU bugs are race conditions, but race conditions also include lost updates, duplicate processing, and other timing-dependent defects.

How do attackers exploit race conditions?

By deliberately sending many concurrent requests to widen the timing window, a technique called request racing. This is used to double-redeem coupons, overdraw balances, or bypass one-per-account limits before the guarding flag is written.

What is the most reliable fix for the bank-balance race?

Push the check and the act into a single atomic database statement, such as UPDATE ... SET balance = balance - :amount WHERE balance >= :amount, and back it with a CHECK (balance >= 0) constraint. That removes the exploitable gap entirely rather than narrowing it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.