Web Race Conditions: TOCTOU & Limit-Overrun Attacks
How web race conditions cause TOCTOU and limit-overrun exploits, why concurrent requests beat single-use checks, and how locking and idempotency defend.
Most web vulnerabilities are about content: a payload that means something dangerous. Race conditions are about timing. The request looks completely legitimate. What makes it an attack is that many identical requests arrive at the same instant, and the application, written as though requests take turns, lets several of them slip through a gate that should have admitted one.
The result can be a gift card redeemed a dozen times, a bank balance driven negative, a rate limit rendered meaningless, or a one-time invitation used repeatedly. The code passes review because each line is correct in isolation. The flaw lives in the space between two lines that the developer assumed nothing could occupy.
Time-of-check to time-of-use
The general name for the pattern is TOCTOU, time-of-check to time-of-use. A program checks a condition, then acts on the assumption that the condition still holds. If anything can change the condition in the interval between the check and the use, the assumption is unsafe.
In a web application the shared state is usually a row in a database. The typical vulnerable sequence looks like this:
1. read the voucher: is it still unused? (check)
2. it is unused, so apply the discount
3. mark the voucher as used (use)
Run this once and it is fine. Run two copies at the same time and both can execute step 1 before either reaches step 3. Both read the voucher as unused. Both apply the discount. Both then mark it used, one harmlessly after the other. The check that was meant to enforce single use enforced nothing, because two requests observed the same pre-change state. On MITRE ATT&CK, exploiting an internet-facing app this way maps to Exploit Public-Facing Application (T1190). The broader mechanics of the check-to-use gap are covered in TOCTOU Race Condition Explained.
Limit-overrun attacks
The most common and most profitable web race is the limit-overrun attack. A resource is supposed to be used a bounded number of times, once, or up to a balance, or within a quota, and the attacker exceeds that bound by squeezing extra uses into the timing window.
| Target | Intended limit | Overrun result |
|---|---|---|
| One-time voucher or gift card | Redeem once | Redeemed many times for stacked value |
| Account balance or wallet | Spend up to the balance | Overdraft or double-spend |
| Withdrawal or transfer | Move only funds you hold | Move the same funds twice |
| Rate or usage quota | N actions per window | Far more than N slip through |
| Invitation or signup bonus | One per user | Repeated bonuses claimed |
| Stock or inventory | Sell available units | Oversell beyond stock |
Each of these is a check-then-act on a counter or a flag. Each falls to the same technique. The financial ones are why race conditions attract determined attackers: the exploit converts directly into money, and a single successful burst can be repeated.
Forcing the window open
A race condition is a probabilistic bug. The attacker's job is to raise the probability that multiple requests execute inside the check-to-use window at the same time. The way to do that is to make the requests arrive together, as close to truly simultaneous as the network allows.
Naively sending requests one after another rarely works, because normal network jitter spreads their arrival out and the first request finishes its check-and-act before the next begins. Attackers instead prepare many requests in advance and release them in a tight burst, so they land within microseconds of each other and collide inside the window. Research on so-called single-packet techniques pushed this further by getting many requests to complete on the server at effectively the same moment, largely removing network timing as a source of noise. The conceptual point for a defender is simple: assume an attacker can make requests arrive genuinely concurrently, and design as though they will.
If your logic reads shared state, decides, and then writes in separate steps, assume an attacker can land many requests inside that gap at once. The safety of check-then-act code depends on requests taking turns, and an attacker specifically removes that assumption. Any invariant enforced across two application steps rather than one atomic operation is a candidate for a limit-overrun attack.
Why application-level checks are not enough
The instinct is to add a stronger check: read the flag, and if it is already set, refuse. This does not help when the problem is that two requests both read the flag before either sets it. Adding more checks in application code does not close the window; it just moves it. As long as the read and the write are distinct operations that another request can interleave between, the race remains.
The same reasoning defeats naive rate limiting as a fix. A limiter that reads a counter, compares it, and increments it in separate steps is itself a check-then-act, and it can be raced exactly like the resource it was meant to protect. Rate limiting is useful for other reasons, covered in Rate Limiting Explained, but it is not a cure for the race.
Where the windows hide
The check-to-use gap is easy to state and hard to spot, because it rarely looks like a gap in the source. A few patterns account for most web races.
The first is the read-then-write across a request. Any handler that fetches a record, inspects a field, decides, and then saves a change is a check-then-act, and if it runs outside a single atomic operation, two requests can interleave. Voucher redemption, balance spending, and quota counting almost always take this shape.
The second is the uniqueness gap. Application code that checks whether a value already exists and, finding none, inserts it, can be raced into inserting duplicates when two requests both find nothing before either writes. Signup flows, invitation claims, and one-per-user bonuses fail here when the uniqueness is enforced only in application logic rather than by a database constraint.
The third is the multi-object invariant. Some rules span more than one record, moving funds from one account to another, or decrementing stock while creating an order. These involve several reads and writes, which widens the window and multiplies the ways concurrent requests can leave the data inconsistent.
The fourth is the distributed check. When the check and the action live in different services, or when a cache holds the state being checked, the window stretches across a network hop. The larger the gap in time and space between deciding and acting, the easier it is for an attacker to fit a colliding request inside it.
How to actually defend against web race conditions
Every durable defense does the same thing: collapse the check and the action into one indivisible step so no other request can slip between them.
- Enforce the invariant in the database. Put the rule where atomicity is native. A unique constraint makes a second redemption of the same voucher fail outright. A conditional update that only decrements a balance when sufficient funds exist, executed as one statement, cannot be split by a concurrent request.
- Use atomic operations, not read-modify-write. Prefer a single atomic decrement or compare-and-set over reading a value into the application, changing it, and writing it back. The round trip is the window.
- Lock the row for the transaction. When logic genuinely needs multiple steps, take an exclusive lock on the affected record at the start of the transaction so concurrent requests serialize and wait their turn rather than overlapping.
- Make operations idempotent. Require an idempotency key on state-changing requests and record it, so a repeated or replayed request produces the original result once rather than acting again. This neutralizes bursts of identical requests, which is exactly what the attack sends.
- Keep the critical section small and single-writer. The shorter and more atomic the check-and-act, the smaller the window. Route contended operations through one path that owns the invariant rather than duplicating the logic in several endpoints.
- Test under real concurrency. Fire bursts of simultaneous requests in testing and assert the invariant holds. Sequential tests will pass while the app is wide open, so the test has to reproduce the collision.
There is one idea behind every fix: remove the interval between check and use. If deciding and acting happen as a single atomic operation, whether a unique constraint, a conditional update, a row lock, or an idempotency key, there is no window for a second request to exploit, no matter how many arrive at once. Guard the invariant in one indivisible step and the race disappears.
A worked example: the gift card that pays many times
Walking the voucher case through concurrent execution makes the window concrete.
A user holds a gift card worth a fixed amount and a checkout that applies it. The handler, written the obvious way, reads the card to confirm it has a positive balance, applies that balance to the order, and then sets the card balance to zero. Executed by one request at a time, this is correct. The card pays once and is emptied.
Now the attacker sends ten checkout requests in a tight burst, each trying to apply the same card to a separate order, all arriving within a few milliseconds. The server runs them concurrently. Several of them execute the read step before any of them reaches the write step. Each of those requests sees a card with a positive balance, because none of the writes has landed yet. Each applies the full balance to its own order. Each then sets the balance to zero, one after another, harmlessly overwriting a value that is already zero. The card that should have paid once has now paid for several orders. The attacker converted a single stored value into multiple discounts, and the money is real.
The code passed review because every line is correct on its own. The read is correct. The comparison is correct. The write is correct. The defect lives in the assumption that nothing could execute between the read and the write, and the attacker's burst is a direct assault on that assumption.
The same shape produces every limit-overrun variant. Swap the gift card for an account balance and the overrun is an overdraft. Swap it for a one-per-user signup bonus and the overrun is repeated bonuses. Swap it for stock on hand and the overrun is overselling inventory the business does not have. The mechanism is identical each time: a check and an act separated by a window, and several requests admitted into that window at once.
Detection signals
Race conditions rarely announce themselves in logs the way an injection attempt does, so detection combines application-level invariant monitoring with attention to request patterns.
- Bursts of near-identical state-changing requests. Many requests to the same sensitive endpoint, from the same session or token, arriving within a very short window, is the signature of an attacker trying to fit multiple requests into the check-to-use gap. Ordinary users do not redeem the same voucher ten times in fifty milliseconds.
- Invariant violations discovered after the fact. A voucher marked used more than once, a balance that went negative, a quota exceeded, a resource sold beyond its stock. Monitoring the invariants themselves, rather than only the requests, catches a successful race even when the traffic looked plausible.
- Duplicate records that a uniqueness rule should have prevented. Two accounts with the same supposedly unique value, or two claims of a one-per-user reward, point to a uniqueness check enforced only in application code and raced into inserting duplicates.
- Reconciliation gaps. Financial or inventory totals that do not add up during reconciliation are often the downstream evidence of a limit-overrun that succeeded quietly.
- Repeated identical idempotency-free submissions. State-changing requests that carry no idempotency key and repeat the same payload rapidly are exactly the traffic that atomic and idempotent handling is meant to neutralise, and their presence flags endpoints that lack it.
The most reliable of these is invariant monitoring, because it detects the outcome of a race rather than trying to recognise the traffic that caused it. If the rule is that a voucher pays once, an alert on any voucher that paid twice catches the attack regardless of how the requests were shaped.
Finding races before an attacker does
Because the malicious request looks legitimate, testing has to reproduce the collision rather than the payload. Sequential tests pass while the application is wide open, so the test must fire genuinely concurrent requests and then assert the invariant held.
The practical method is to prepare a batch of identical state-changing requests and release them together against a test environment, then check the resulting state. Redeem one voucher with twenty simultaneous requests and confirm it paid exactly once. Spend a balance with concurrent withdrawals and confirm it never went negative. Claim a one-per-user bonus in parallel and confirm exactly one landed. Any endpoint that reads shared state, decides, and writes in separate steps is a candidate, and the test either proves it is atomic or exposes the window.
A test suite that exercises endpoints one request at a time will pass against code that is completely exploitable, because the race only appears under concurrency. Coverage numbers give false comfort here. The only test that matters for this class is one that lands multiple requests inside the same window on purpose and then checks that the invariant survived. If your test never creates the collision, it never tests the thing that breaks.
How the understanding evolved
The check-to-use gap is an old idea from systems programming, where time-of-check to time-of-use bugs were long understood in the context of files and shared memory. The web inherited the same pattern once applications began enforcing limits over shared database state across many concurrent requests.
For a long time these bugs were treated as unreliable, because normal network jitter spread requests out and made the collision hard to hit on purpose. Research into techniques that get many requests to complete on the server at effectively the same instant removed much of that timing noise, which made web races far more reproducible and raised their profile from a rare curiosity to a class worth testing for directly. The defensive answer did not change with the tooling. Atomicity in the database was always the durable fix, and the improved attack reliability mostly made the case for applying it everywhere the invariant matters.
Comparing the defenses
The durable fixes all collapse the check and the act into one indivisible step, and they differ in where and how they do it.
| Defense | Where it acts | Best fit | Limitation |
|---|---|---|---|
| Unique constraint | Database schema | Uniqueness and single-use rules | Only expresses uniqueness, not arbitrary logic |
| Conditional atomic update | Single SQL statement | Balance and counter limits | Requires the rule to fit one statement |
| Row lock in a transaction | Database transaction | Genuinely multi-step logic | Adds contention and must be scoped tightly |
| Idempotency key | Application plus store | Neutralising repeated submissions | Requires clients to send and servers to record keys |
| Small single-writer section | Architecture | Contended operations across endpoints | Needs the logic centralised in one path |
The table is a menu rather than a ranking. A single-use voucher is best served by a unique constraint. A balance is best served by a conditional update that only debits when funds suffice. Multi-object invariants that cannot fit one statement call for a row lock. Bursts of identical replays are absorbed by idempotency keys. Most real systems combine several, choosing per invariant.
Common misconceptions
The first misconception is that adding a stronger check fixes the race. Reading the flag again, or checking it more carefully, does nothing when the problem is that two requests both read the flag before either sets it. More checks in application code move the window without closing it.
The second misconception is that rate limiting solves it. A limiter that reads a counter, compares it, and increments it in separate steps is itself a check-then-act, and it can be raced exactly like the resource it guards. Rate limiting is useful for other reasons and does not cure the race.
The third misconception is that races are too unreliable to matter. Modern techniques make requests arrive concurrently enough that the collision is reproducible, and the financial variants convert directly into money. Treating the bug as improbable is how it survives to production.
The fourth misconception is that an application-layer transaction alone is enough. Wrapping the read and write in a transaction without taking the right lock or using an atomic operation can still allow both requests to read the same pre-change state under common isolation levels. The atomicity has to be real, through a constraint, a conditional update, or an explicit lock, rather than nominal.
Frequently asked questions
What is a web race condition in one sentence?
It is a bug where an application checks a condition and then acts on it in separate steps, and an attacker fires concurrent requests so that several pass the check before any of them completes the act, producing more successes than the rules allow.
Why do race conditions have financial impact?
The common web form is the limit-overrun, where a resource meant to be used a bounded number of times is used more. Redeeming a single voucher many times, overdrawing a balance, or claiming repeated bonuses each converts directly into money, which is why these bugs attract determined attackers.
Can rate limiting stop a race condition?
No, at least not on its own. A naive limiter is itself a check-then-act on a counter and can be raced exactly like the resource it protects. Rate limiting has real value for abuse control, and the cure for the race is atomicity in the operation that enforces the invariant.
How do I make an operation atomic?
Collapse the check and the act into one indivisible step. Enforce uniqueness with a database constraint, enforce a balance with a conditional update that only debits when funds suffice, take an exclusive row lock when the logic genuinely needs multiple steps, and use idempotency keys to absorb repeated submissions.
How do I test for race conditions?
Fire a burst of genuinely concurrent, identical state-changing requests at a test environment and then assert the invariant held. Sequential tests pass against fully exploitable code, so the test has to create the collision on purpose, redeeming one voucher with many simultaneous requests and confirming it paid exactly once.
Is TOCTOU the same as a web race condition?
TOCTOU, time-of-check to time-of-use, is the general pattern of acting on a condition that may have changed since it was checked. Web race conditions are that pattern applied to shared application state across concurrent requests, and limit-overrun attacks are the common web expression of it.
Web race conditions are unusual among web bugs because the malicious request is indistinguishable from a valid one. The defense cannot be to recognize a bad payload; there is no bad payload. It has to be structural: make the operation atomic so that concurrency, however aggressive, cannot produce two successes where the rules allow one. Enforce the limit in the database, lock what must be multi-step, and make state changes idempotent, and the attacker's burst of perfectly legitimate requests simply resolves to a single legitimate outcome. To see how public-facing exploitation trends over time, cross-reference our Exploit Intelligence dashboard.
Related guides
Sources & further reading
Related guides
- Clickjacking Explained: UI Redress Attacks & Frame Defenses
How clickjacking hijacks a user's clicks with transparent iframes, what an attacker can trigger, and how to block framing with headers and CSP.
- Command Injection: Shell Metacharacters & Safe APIs
How OS command injection breaks out of an intended command via shell metacharacters, and why argument-array APIs beat string concatenation.
- Content Security Policy Explained: Stopping XSS with CSP
How Content Security Policy works: the directives that matter, how it blocks XSS and injection, nonces vs hashes, and a safe report-only rollout.