Understanding Race Condition Vulnerabilities in Web Applications
AI generated
OWASP
0x00
Race Conditions · Concurrency
Race Condition Vulnerabilities in Web Applications
How two nearly simultaneous requests can bypass checks that look completely correct in isolation

A race condition arises when two or more requests read and modify the same data nearly simultaneously without the application coordinating this shared access, letting an attacker bypass a check that works correctly under single execution by deliberately firing multiple requests at once. Such vulnerabilities are especially tricky because the affected code appears completely bug-free under normal, sequential testing, and the problem only surfaces under deliberately induced, high concurrency.

16 min read Race Conditions Concurrency

1. The TOCTOU pattern: time-of-check to time-of-use

The classic pattern behind nearly every race condition vulnerability is called TOCTOU, short for time-of-check to time-of-use, and describes the time span between the moment an application checks a condition (say, "does this user still have enough balance?") and the moment it actually acts based on that check (say, "deduct the balance"). Between these two steps there is practically always a small but nonzero time span in which a second, nearly simultaneous request can successfully pass exactly the same, still-valid check before the first deduction has even taken effect.

Deliberately exploited, this gap lets an attacker redeem a coupon code that, per the business logic, should only be redeemable once, ten times over by firing ten identical requests simultaneously, because each individual request finds a "not yet redeemed" state at the moment of its check, regardless of nine other requests doing exactly the same thing in parallel right now. This concrete scenario is known as double-spending and is one of the most common practical manifestations of race condition vulnerabilities in e-commerce applications.

2. Typical places to find them in a Symfony application

Race conditions occur anywhere a read access and a write access building on it are logically implemented as two separate steps in application code, instead of as a single, atomic database operation, a pattern that easily arises unintentionally in object-oriented ORMs like Doctrine, since entity objects get loaded into application code, modified in PHP code, and only written back via flush at the end of the request. Typical candidates are balance and account-balance operations, coupon and discount code redemptions, stock checks during checkout, and any form of rate limiting based on a simple counter in the database.

Even seemingly harmless functions like "verify account once" or "invitation link usable only once" are classic race condition candidates, because the check "has this link already been used?" and the subsequent marking as used are implemented in many implementations as two separate, non-atomic database accesses, even though they belong inseparably together from a business perspective.

3. A vulnerable code example with Doctrine

The following example shows a typical, at-first-glance unremarkable pattern: load balance, check against the order value, deduct if sufficient balance, and save. Under sequential execution this code works flawlessly, but under simultaneous requests from the same user it can be reliably abused to deduct the same balance multiple times, because no lock exists between reading the balance and saving the new value that would stop a second, parallel request from reading exactly the same, not-yet-updated value.


<?php
declare(strict_types=1);

// VULNERABLE: classic TOCTOU pattern
public function deduct(User $user, Money $amount): void
{
    $account = $this->accountRepository->find($user->getAccountId());
    if ($account->getBalance()->lessThan($amount)) {
        throw new InsufficientBalanceException();
    }
    // Time window: a parallel request can read exactly
    // the same, not-yet-updated balance right here.
    $account->setBalance($account->getBalance()->subtract($amount));
    $this->entityManager->flush();
}

4. The fix: atomic database operations instead of read-then-write

The most robust fix against this class of vulnerabilities is to move the check and the change into a single, atomic database operation instead of implementing them as separate read and write steps in PHP code, so the database itself, whose transaction engine is designed for exactly such cases, guarantees consistency. A conditional UPDATE that integrates the balance check directly into the WHERE clause of the update is immune to race conditions, since the database locks the affected row for the duration of the operation and a second, simultaneous UPDATE attempt either waits or fails based on the already-updated value.

This conditional UPDATE technique can be implemented with Doctrine via a native DQL or SQL query that checks the returned affected-row count, instead of relying on a previously loaded entity object whose state may already be stale by the time of the flush.


<?php
declare(strict_types=1);

// SAFE: atomic, conditional UPDATE, race-condition immune
public function deduct(User $user, Money $amount): void
{
    $connection = $this->entityManager->getConnection();
    $updatedRows = $connection->executeStatement(
        'UPDATE account SET balance = balance - :amount
         WHERE user_id = :userId AND balance >= :amount',
        ['amount' => $amount->getAmount(), 'userId' => $user->getId()]
    );

    if ($updatedRows === 0) {
        throw new InsufficientBalanceException();
    }
}

5. Pessimistic locking as an alternative for complex logic

When business logic is too complex for a single, conditional UPDATE, say because several related tables need to be updated consistently within the same transaction, pessimistic locking offers an alternative: Doctrine supports `LockMode::PESSIMISTIC_WRITE`, which requests a database lock (`SELECT ... FOR UPDATE`) when loading an entity and thereby blocks all other transactions wanting to lock the same row until the end of the current transaction.

Pessimistic locking is conceptually simpler to understand than conditional updates, but has a noticeable performance downside under high concurrency, since waiting requests are actually blocked instead of returning immediately with an error, which is why it's best suited for rare but business-complex operations, while simple counter or balance updates are usually better served by the lighter-weight conditional UPDATE.

6. Deliberately testing for race conditions

Since race conditions stay invisible under normal, sequential test execution, a deliberate test is needed that fires several identical requests genuinely simultaneously at the same resource, say via parallel HTTP clients or a specialized tool like Turbo Intruder from Burp Suite, which was built for exactly this use case and guarantees a very precise, minimal time offset between the fired requests.

A simple but effective manual test is to call the same potentially vulnerable endpoint with a tool like `curl` in a loop with the `&` background operator ten or twenty times in parallel, then check whether the resulting database state still matches the business invariants, say whether a coupon, despite twenty simultaneous redemption attempts, was actually only marked as redeemed once.

7. Idempotency keys for client-side triggered race conditions

Some race conditions don't arise from malicious intent but from a real user's double click, or from a frontend's automatic retry attempt under a slow network, say a "submit order" button firing twice. For these cases an idempotency key is the established solution: the client generates a unique key per user action, sends it along with every (including repeated) request, and the server processes an already-known idempotency key only once, every further request with the same key simply returns the already-computed result instead of re-executing the operation.

Idempotency keys solve a different but related problem than locking strategies: locking protects against simultaneous access to shared resources by different users or requests, idempotency keys protect against the accidental multiple execution of the same, meant-to-be-single user action, both measures usefully complement each other in many checkout and payment flows.

8. Detecting race conditions in production

Even after careful hardening, it's worth monitoring for anomalous patterns that could indicate an exploited race condition finding, say multiple redemptions of the same coupon code within a few milliseconds, or a negative account balance that should, under correctly implemented logic, never occur. A simple but effective approach is a periodic consistency check that verifies business invariants ("every coupon has at most one redemption", "no balance is negative") against the actual database state and alerts on deviations, regardless of whether the deviation stems from an attack or a previously undetected programming bug.

Additionally, structured logging of every security-relevant state transition (say, "coupon X redeemed by request Y at time Z") helps reconstruct after the fact whether a conspicuous double finding actually traces back to an exploited race condition, rather than a legitimate but unusual business event.

9. Protection strategies at a glance

The table below compares the protection strategies against race conditions presented.

Strategy Suited for Downside
Conditional UPDATE Simple counter/balance operations Not suited for complex multi-table logic
Pessimistic locking Complex, rare operations Blocks waiting requests, performance cost
Idempotency key Client-side double-click/retry protection Doesn't solve the multi-user locking problem
Optimistic locking Rare conflicts, high read frequency Requires retry logic on conflicts

Mironsoft

Security audits, OWASP-compliant hardening, and secure architecture

Applications that actually hold up against a real attack attempt?

We review existing applications for classic OWASP vulnerabilities, insecure authentication, and missing input validation, then build an architecture that structurally reduces attack surface instead of just patching individual symptoms.

Security Audit

Systematically checking OWASP Top 10, auth flows, and input validation for vulnerabilities.

Secure Architecture

Building rate limiting, encryption, and access controls correctly from the ground up.

Incident Readiness

Establishing logging, monitoring, and response processes for when things go wrong.

10. Summary

Race Conditions: The Essentials at a Glance

Core idea

TOCTOU: between check and action lies a time window in which a parallel request still passes the same, still-valid check.

Best fix

Check and change as a single atomic database operation (conditional UPDATE) instead of separate read/write steps.

Testing

Race conditions stay invisible under sequential testing, deliberately parallel requests are needed to uncover them.

Idempotency

Idempotency keys protect against accidental multiple execution from double clicks or network retries.

11. FAQ: Race Conditions: The Essentials at a Glance

1What exactly does TOCTOU mean?
Time-of-check to time-of-use: the window between a condition check and the action based on it, during which a parallel request still successfully passes the same check.
2Why isn't a database transaction alone enough?
A transaction only guarantees that its own operations act atomically, but doesn't prevent a second request from reading the same stale starting value, as long as reading and writing are separate steps.
3Is pessimistic locking always the safest choice?
It's safe but comes with a performance cost, for simple counter updates a conditional UPDATE is usually the better, lighter-weight solution.
4How do I manually test for a suspected race condition?
Call the same endpoint with curl in a loop using the & background operator multiple times in parallel and check the resulting database state against the business invariants.
5What's the difference between locking and idempotency keys?
Locking protects against simultaneous access from different requests to the same resource, idempotency keys prevent accidental multiple execution of the same user action.
6Are race conditions only relevant for monetary amounts?
No, invitation links, verification flows, stock levels, and rate limiting are classic places to find them too.
7Does a normal code review reliably catch race conditions?
Rarely, since the code looks correct under sequential mental review, deliberate concurrency tests are usually needed.
8Does optimistic locking help against double-spending?
Yes, if conflicts are rare and retry logic exists, under very high concurrency a conditional UPDATE is usually more robust.
9Where should I generate an idempotency key?
Client-side per user action, often as a UUID that stays unchanged on every retry attempt of the same action.
10Can an ORM like Doctrine automatically prevent race conditions?
Not automatically, an ORM merely provides the tools (conditional updates, locking modes), developers must deliberately apply them.