Race condition vulnerability: how testers find it and how to fix it

A race condition vulnerability exists when a web app checks a rule and then acts on it, and parallel requests slip through the gap. The classic case is a single-use voucher redeemed twice. Testers send the same request in parallel with test data and read the state back. Developers fix it with atomic updates, row locks, unique constraints and idempotency keys.

What is a race condition vulnerability?#

A race condition vulnerability exists when a web application checks a rule and then acts on it in two separate steps, and an attacker sends requests in parallel so that several of them pass the check before any of them updates the state. The standard entry is CWE-362: a code sequence needs exclusive access to a shared resource, but a timing window lets another sequence change that resource. The check-then-act form has its own entry, CWE-367, time-of-check time-of-use (TOCTOU).

Many readers know the term from operating systems, where a race means two threads touch the same memory. In a web application the shared resource is usually a database row: a balance, a voucher, an invite count or a login attempt counter. The competing threads are ordinary HTTP requests. A deadlock is a different problem. Two operations wait on each other's locks and neither proceeds, while a race gives a wrong answer.

Testing for this bug needs written authorization from the system owner.

How does a web race condition work?#

A web race condition works because the server handles requests concurrently, and the code between the check and the update takes long enough for a second request to arrive. When the check and the update are separate steps, both requests see the same old state.

Single-use voucher race (fictional app)

This is a demonstration. Harbor Shop is a fictional store on shop.example.com, and no real customer system was tested.

The voucher code WELCOME10 may be used once per account. The vulnerable handler runs two steps:

1. SELECT used FROM vouchers WHERE code = 'WELCOME10' AND account_id = 42;   -- returns used = false
2. UPDATE vouchers SET used = true WHERE code = 'WELCOME10' AND account_id = 42;
   ... then apply a 10 EUR discount to the cart

A tester with a test account sends the same request ten times, all inside one network packet:

POST /api/cart/apply-voucher HTTP/2
Host: shop.example.com
Cookie: session=TEST_ACCOUNT_SESSION
Content-Type: application/json

{"code":"WELCOME10"}

Safe result: one 200 OK with a discount, nine 409 Conflict. Vulnerable result: four to six responses say 200 OK, and a read-back of the cart shows a discount of 40 to 60 EUR instead of 10.

Here is what the database sees. Requests A and B both run step 1 before either runs step 2, so both read used = false.

Time Request A Request B
t1 Reads used = false
t2 Reads used = false
t3 Writes used = true, applies discount
t4 Writes used = true, applies discount

The attacker needs only the ability to send requests close together. For years that was hard, because network jitter spreads arrival times across several milliseconds. PortSwigger Research removed most of the jitter. Its single-packet attack, published on 9 August 2023, completes 20 to 30 requests inside one TCP packet, and the paper reports that it can squeeze 30 requests sent from Melbourne to Dublin into a sub-1 ms execution window. A race that once needed luck now repeats on demand.

Which types of race condition vulnerability exist?#

The common web types are limit overrun, multi-endpoint races, single-endpoint races and partial construction races. PortSwigger's Academy topic and the Smashing the state machine paper describe them. This table adds a test and a fix for each row.

Type What happens Example target Test Fix
Limit overrun A one-time or capped action runs more times than allowed Voucher, gift card, withdrawal, vote, invite quota N parallel copies of one request Atomic conditional update, row lock
Attempt counter bypass Parallel guesses beat a counter Login, OTP and 2FA code entry, reset codes N parallel guesses with a known wrong value Atomic counter, lock per account
Multi-endpoint Two different requests race on one record Add to cart racing with checkout Send both requests in one parallel group One transaction that locks the record
Single-endpoint Two requests to one endpoint with different values collide Two email-change requests sent together Parallel requests with different parameters Serialize per user, validate on write
Partial construction A record exists briefly in a half-built state A new account before its token is set Request the object during creation Create the record and its secrets in one transaction
Single-use token A reset or confirmation token is consumed twice Password reset link, email confirmation Parallel use of one token Delete and return the token in one statement

Time-sensitive tokens are a related class. When an app builds a token from a timestamp, two requests in the same instant can receive the same value. PortSwigger covers this under time-sensitive attacks.

Which real incidents and CVEs involved race conditions?#

Public cases show that the bug is real. Each row below links to its source and date.

Date Case What the source says
21 May 2015 Starbucks gift cards, Egor Homakov The author sent parallel transfer requests from a wallet holding $5. On the 6th attempt the app created two $5 transfers, leaving two cards with $15 and $5. He wrote that the only right fix is a pessimistic lock (the FOR UPDATE clause) (Sakurity).
3 April 2019 CVE-2019-5421, Devise (Ruby on Rails authentication) NVD records a CWE-367 flaw in the lockable module's increment_failed_attempts method: multiple concurrent requests can prevent an attacker from being blocked on brute force attacks. Fixed in Devise 4.6.0 (NVD).
9 August 2023 Smashing the state machine, PortSwigger Research James Kettle presented the single-packet attack and a method for hidden multi-step races, with case studies on high-profile websites and on Devise (PortSwigger).

The Devise case matters to defenders because the racy code was a lockout counter, which is itself a security control. A counter that parallel requests can skip stops locking anyone out.

How do penetration testers detect and prove a race condition?#

Testers prove a race with a repeatable parallel test, a control run and a read-back of the final state. One odd response is a clue and not proof of an effect. The OWASP Web Security Testing Guide covers timing flaws in WSTG-BUSL-04. PortSwigger's method has three phases: predict collisions, probe for clues, prove the concept.

  1. Predict. Pick endpoints where a limit exists and where two requests touch the same record: vouchers, balances, invites, coupons, attempt counters and token redemption.
  2. Benchmark. Send the request once, then several times in sequence, and record the normal responses. This shows what correct behavior looks like.
  3. Probe. Send the same requests in parallel. In Burp Repeater, put the requests in a group and choose "Send group in parallel". PortSwigger says Burp uses the single-packet attack on HTTP/2 and last-byte synchronization on HTTP/1. Turbo Intruder suits cases that need scripting.
  4. Run controls. Repeat the parallel test with a nonexistent voucher, with a voucher owned by another test account, and with an already used voucher. These must fail every time. If they succeed, the cause is something other than a race.
  5. Read back. Fetch the final state through a normal request, or from a second test account. A 200 OK alone is not evidence.

Our method for all findings is the same: a write is proven by reading the changed record back, and destructive proofs use only test-owned accounts and records marked as test data.

A minimal script for a test you are authorized to run against your own staging system:

# Authorized staging test only. Test account, test voucher, 10 requests.
import asyncio, httpx

URL = "https://staging.shop.example.com/api/cart/apply-voucher"
HEADERS = {"Cookie": "session=TEST_ACCOUNT_SESSION"}

async def main():
    async with httpx.AsyncClient(http2=True, headers=HEADERS) as client:
        await client.get("https://staging.shop.example.com/api/cart")  # warm the connection
        results = await asyncio.gather(*[
            client.post(URL, json={"code": "TEST-ONCE-001"}) for _ in range(10)
        ])
    print([r.status_code for r in results])

asyncio.run(main())

A plain asyncio script cannot remove network jitter the way the single-packet attack does, so a negative result from it does not clear the endpoint. Record the tool and its settings in the evidence.

Sample evidence record (demonstration)

This is a demonstration with invented numbers. No real system was tested. Control run (second voucher use, sequential): 409. Parallel run, 10 requests: 5 responses 200, 5 responses 409. Read-back of the cart from the same session: discount applied 5 times. Read-back of the voucher row in staging: used = true, redemptions = 5. Timestamps and request files are saved with a SHA-256 manifest.

In production, use a test voucher with a small value, cap the parallelism, skip real payments, and tell the owner before you start.

How do you prevent race conditions?#

Make the check and the update one atomic operation, or lock the record before you read it. Add a database constraint as a backstop, and use idempotency keys for requests that clients may retry.

Application-level flags, in-memory counters and random delays do not work. Homakov's post lists "limiting number of requests per IP/account/action, making a random delay or using DB transactions in a wrong way" as poor protections. A transaction alone does not help when the isolation level lets both transactions read the old value.

Atomic conditional update (SQL, any stack)#

The condition and the write happen in one statement. Count the affected rows.

UPDATE vouchers
SET used = true
WHERE code = 'WELCOME10' AND account_id = 42 AND used = false;
-- affected rows = 1: this request won. affected rows = 0: already used, reject.

Row lock inside a transaction (PostgreSQL)#

The PostgreSQL docs describe SELECT ... FOR UPDATE row-level locks: other transactions that try to lock or update the same rows wait for the first transaction to finish.

BEGIN;
SELECT balance FROM wallets WHERE id = 7 FOR UPDATE;
-- check balance >= amount in application code
UPDATE wallets SET balance = balance - 5 WHERE id = 7;
COMMIT;

Python and Django#

Django's select_for_update() locks rows until the transaction ends, and F() expressions let the database do the arithmetic (Django docs).

from django.db import transaction
from django.db.models import F

with transaction.atomic():
    wallet = Wallet.objects.select_for_update().get(pk=wallet_id)
    if wallet.balance < amount:
        raise ValueError("insufficient funds")
    Wallet.objects.filter(pk=wallet_id).update(balance=F("balance") - amount)

Node.js with pg#

Node.js runs your JavaScript on one thread, but each await returns control to the event loop, so another request can run between a read and a write. Skip the read and let the database check the balance.

const { rowCount } = await pool.query(
  "UPDATE wallets SET balance = balance - $1 WHERE id = $2 AND balance >= $1",
  [amount, walletId]
);
if (rowCount === 0) throw new Error("insufficient funds");

PHP and Laravel#

lockForUpdate() inside a transaction takes a pessimistic lock (Laravel docs).

DB::transaction(function () use ($walletId, $amount) {
    $wallet = DB::table('wallets')->where('id', $walletId)->lockForUpdate()->first();
    if ($wallet->balance < $amount) { abort(422, 'insufficient funds'); }
    DB::table('wallets')->where('id', $walletId)->decrement('balance', $amount);
});

C# and EF Core#

Optimistic concurrency uses a concurrency token. EF Core compares the token's original value with the database value on save and throws if another request changed the row (Microsoft Learn). Catch DbUpdateConcurrencyException, then retry or reject.

Idempotency keys and unique constraints#

Clients retry requests, and attackers replay them. Stripe's API reference shows the pattern: the client sends an Idempotency-Key header, and the server returns the saved result for a repeated key. Store the key under a unique constraint, so two parallel requests with the same key cannot both run. For "once per account" rules, a unique index on (voucher_id, account_id) makes the second insert fail however the code is written.

Bug class Strongest fix Backstop
Limit overrun Atomic conditional update Unique or check constraint
Attempt counters Atomic increment with the limit in the same statement Lock per account
Multi-endpoint One transaction, rows locked in a fixed order Constraint on the final state
Single-use token Delete and return the token in one statement Short expiry
Retried payments Idempotency key with a unique constraint Reconciliation job

Lock rows in a fixed order to avoid the deadlocks described earlier.

Race condition testing checklist#

Run these checks on each feature that has a limit, a balance or a one-time action. Use test accounts and test data.

  1. List every "only once", "at most N" and balance rule in the product.
  2. Record the normal sequential behavior of each rule.
  3. Send 10 parallel copies of the request and compare the result with the sequential run.
  4. Run control requests: invalid object, another account's object, expired object.
  5. Read the final state back from the UI, the API and a second account.
  6. Test login, OTP, 2FA and password reset attempt counters in parallel.
  7. Test token redemption (reset links, invites, email confirmation) with parallel use.
  8. Race two different endpoints that touch the same record, such as cart and checkout.
  9. Check that refunds, cancellations and transfers cannot run twice on one record.
  10. Check that every limit also exists as a database constraint.

When should you get a professional test?#

Automated scanners rarely find races, because the bug depends on application rules and on how requests are timed. A web application penetration test covers the business flows in your product, and an API penetration test covers the same rules at the API layer. Ask the provider how they run parallel tests, which controls they use and how they keep production safe. Related reading: business logic vulnerabilities and rate limiting and credential stuffing.

Questions

What is the difference between a race condition and a deadlock?

In a race condition, the result depends on the order in which concurrent operations run, so an unlucky order produces a wrong result. In a deadlock, two operations each wait for a lock the other holds, and both stop. Locks fix races, but careless locking can cause deadlocks.

Can a WAF stop a race condition?

No. The parallel requests are valid, well-formed requests, and each one looks normal on its own. The bug is in how the application handles them at the same time. The fix belongs in the database or the application code, not in a filter in front of it.

Does rate limiting stop race conditions?

Not on its own. A rate limiter that counts requests with a read and then a write can be raced itself, so parallel requests all see the old count. PortSwigger documents this as a way around rate and resource limits. Make the counter atomic, then rate limiting helps.

Can race conditions happen in JavaScript or Node.js if it is single-threaded?

Yes. Node.js runs your JavaScript on one thread, but every await lets another request run. If code reads a balance, awaits, then writes it, two requests interleave. Separate server processes and the database add more concurrency. Put the check inside one atomic database statement.

How do I test for race conditions safely?

Use a test account, test vouchers and test records, send a small group of parallel requests such as 10, and read the state back afterwards. Stay inside written scope. Do not run parallel tests against real balances, real payments or real customer data.

Sources

  1. PortSwigger Web Security Academy: Race conditions (checked 9 November 2026)
  2. PortSwigger Research: Smashing the state machine, the true potential of web race conditions (James Kettle, 9 August 2023) (checked 9 November 2026)
  3. OWASP WSTG-BUSL-04: Test for Process Timing (checked 9 November 2026)
  4. CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization (Race Condition) (checked 9 November 2026)
  5. CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition (checked 9 November 2026)
  6. Sakurity: Hacking Starbucks for unlimited coffee (Egor Homakov, 21 May 2015) (checked 9 November 2026)
  7. NVD: CVE-2019-5421 (Devise lockable module) (checked 9 November 2026)
  8. PostgreSQL documentation: Explicit locking (checked 9 November 2026)
  9. Django documentation: QuerySet API, select_for_update() (checked 9 November 2026)
  10. Laravel documentation: Database query builder, pessimistic locking (checked 9 November 2026)
  11. Microsoft Learn: Handling concurrency conflicts in EF Core (checked 9 November 2026)
  12. Stripe API reference: Idempotent requests (checked 9 November 2026)
  13. Node.js documentation: The event loop, timers, and process.nextTick() (checked 9 November 2026)