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.
Table of Contents
- 1. The TOCTOU pattern: time-of-check to time-of-use
- 2. Typical places to find them in a Symfony application
- 3. A vulnerable code example with Doctrine
- 4. The fix: atomic database operations instead of read-then-write
- 5. Pessimistic locking as an alternative for complex logic
- 6. Deliberately testing for race conditions
- 7. Idempotency keys for client-side triggered race conditions
- 8. Detecting race conditions in production
- 9. Protection strategies at a glance
- 10. Summary
- 11. FAQ
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.