Race Condition Vulnerabilities in Web Applications

Race Condition Vulnerabilities in Web Applications

Race condition vulnerabilities show up whenever a web application checks a condition and acts on it in two separate steps, and an attacker manages to slip a second request in between those steps before the first one finishes. For a security engineer running daily scans against production apps, this is one of the harder bug classes to catch automatically, because the app looks perfectly fine under normal, sequential traffic – it only breaks when requests arrive in parallel.

The classic example is a coupon code. A checkout flow checks “has this code been used?”, finds it hasn’t, then marks it as used and applies the discount. If an attacker fires 20 identical requests within the same 50-100ms window, several of them can pass the “has this code been used?” check before any of them reach the “mark as used” step. Suddenly one $10 discount code has been applied twenty times, and the finance team is asking why.

Why race condition vulnerabilities are so easy to miss

Most functional QA and even most manual penetration testing happens one request at a time. A tester clicks “apply coupon,” sees it work once, moves on. The bug only appears under concurrent load, which is precisely the condition normal testing doesn’t create.

There’s also a myth worth killing here: many developers assume that because their database uses transactions, race conditions are automatically handled. That’s not true unless the transaction isolation level and locking strategy are chosen deliberately. A read-committed transaction that does a SELECT followed by an UPDATE in two separate statements still has a gap – another connection can read the same “not yet used” state in that gap and act on it. MySQL’s default REPEATABLE READ and PostgreSQL’s READ COMMITTED behave differently here, and developers who copy patterns between the two without understanding isolation levels regularly reintroduce the bug they thought they’d fixed.

Where these bugs actually appear in production apps

A few patterns come up again and again in real audits:

Coupon and promo code redemption, where a code should only be usable once per account or globally. Wallet and balance operations, such as a prepaid credit system where two simultaneous “withdraw $50” requests both read a $50 balance and both succeed, leaving the account at -$50. Rate limiting bypass, where the counter that tracks request volume is incremented after the check rather than atomically with it, letting an attacker slip a burst of requests through before the limiter catches up. Account and MFA state, including password reset flows where a reset token can be validated by two concurrent requests before either invalidates it. Inventory and booking systems, where the last unit of a limited product gets “sold” to more than one buyer at checkout.

James Kettle’s research at PortSwigger, presented at Black Hat USA in 2023 under the title “Smashing the State Machine,” pushed this from a niche bug class into something every bug bounty hunter now checks by default. The key insight was that HTTP/2’s single-connection multiplexing lets an attacker send dozens of requests in a single TCP packet, collapsing network jitter to near zero and turning race windows that used to be theoretical into windows that are trivially exploitable. Burp Suite’s Turbo Intruder extension, using the “single-packet attack” technique, made this practical for testers without custom tooling.

How to test for race conditions before an attacker does

A practical testing sequence looks like this:

First, map every endpoint where state changes based on a prior read – balance checks, code redemption, limited-quantity purchases, invite or referral systems, session or token operations. Second, send the same request 20-50 times concurrently, ideally over HTTP/2 with minimal network jitter, and compare the final state against what a correct sequential execution would produce. Third, look specifically at the gap between a read and a write – if they’re in separate database statements or separate service calls, assume there’s a window. Fourth, check whether the fix relies on client-side throttling (easily bypassed) versus a server-side atomic operation.

An experienced backend engineer treats this the same way they treat SQL injection: assume every “check then act” pattern is vulnerable until proven otherwise with a concurrency test, not a code read-through.

Fixing the underlying issue, not just the symptom

The durable fixes are almost always at the data layer, not the application logic layer. Database-level unique constraints turn a duplicate coupon redemption into a failed INSERT instead of a successful double-apply. SELECT … FOR UPDATE (or SELECT … FOR UPDATE SKIP LOCKED in PostgreSQL) locks the row for the duration of the transaction so a second concurrent request has to wait. Atomic operations like Redis’s INCR or DECRBY handle counters and rate limits without a separate read-then-write step at all. Idempotency keys, the same mechanism Stripe’s API popularized for payment retries, let a server recognize and reject a duplicate request even if it arrives in the same millisecond as the original.

Adding a mutex or a semaphore purely in application code is a common half-fix, and it’s worth naming as a mistake because it’s so tempting: it works fine on a single server instance, then silently stops working the moment the app scales to two or more nodes behind a load balancer, since each node has its own in-memory lock. If the application runs on multiple instances, the lock has to live somewhere shared – the database, or a distributed lock service like Redis with Redlock or ZooKeeper.

Common mistakes worth naming directly

Three patterns keep showing up in audits. Teams add client-side “double submit” prevention (disabling a button after click) and consider the vulnerability closed, when server-side timing is what actually matters. Teams test fixes with a handful of manual clicks instead of an actual concurrency tool, so the fix looks verified but was never really exercised under load. And teams fix the one endpoint that got reported in a bug bounty or pen test without auditing every other “check then act” flow in the same codebase, so the same bug class resurfaces three months later in the referral system instead of the coupon system.

Frequently asked questions

Is a race condition the same thing as a TOCTOU bug?
Yes, in practice they’re used interchangeably in web application security. TOCTOU (time-of-check to time-of-use) describes the mechanism – a gap between checking a condition and acting on it – while “race condition” describes the outcome when concurrent execution exploits that gap. CWE-362 covers this bug class formally.

Can automated scanners reliably detect race conditions?
Coverage is improving but it’s still an area where fully automated detection is harder than for injection-style bugs, since it requires generating precisely timed concurrent traffic against state-changing endpoints rather than analyzing a single request-response pair. Automated daily scanning is still valuable for catching the surrounding issues – missing security headers, outdated software, exposed endpoints – that often sit next to concurrency bugs in the same immature codebase, and for flagging endpoints worth manual concurrency testing.

Do rate limits protect against race condition attacks?
Only if the rate limiter itself is implemented atomically. A rate limiter that reads a counter, checks it against a threshold, and then increments it in a separate step has exactly the same TOCTOU gap as the coupon code example, and attackers specifically target rate limiters with burst requests for this reason.

Race conditions don’t announce themselves in a stack trace or a WAF log the way an SQL injection attempt does – they show up as a finance discrepancy, a duplicated order, or an account balance that doesn’t add up, often days after the actual exploit happened. Treating every check-then-act flow as a concurrency risk by default, and pushing the fix down to an atomic database or cache operation rather than an application-level flag, is what separates a codebase that survives a Turbo Intruder run from one that doesn’t.