Why race conditions are the blind spot in most security testing
A traditional vulnerability scanner sends one request, waits for one response, and evaluates it in isolation. That model works for injection and XSS — but it can never find a race condition, because a race condition doesn't exist in a single request. It exists in the gap between two requests that arrive close enough together that the application's locking, validation, or transaction handling can't keep up.
This is why race conditions are classified under OWASP A04:2021 — Insecure Design rather than a specific injection category: the flaw isn't a missing input filter, it's an assumption baked into the application's architecture that requests arrive one at a time. As systems move to microservices and async processing, that assumption breaks more often, not less.
The three race condition patterns that matter most
Concurrent state mutation (CWE-362)
The same state-changing request fired simultaneously multiple times, and the endpoint processes every copy instead of rejecting duplicates. This is the classic "redeem the same coupon 10 times" or "claim the same reward twice" pattern.
Time-of-check-time-of-use — TOCTOU (CWE-367)
The application checks whether an action is allowed (stock available, quota remaining, invite still valid), then acts on that check a moment later. If the state can change in the gap between check and use, an attacker can act after the check passed but before the state updated.
Rate-limit bypass via concurrency (CWE-770)
Rate limiters that count requests after processing them, rather than before, can be overwhelmed by firing a burst of requests inside the same evaluation window — bypassing brute-force protection and API quotas entirely.
HTTP proof-of-concept: three real exploitation patterns
1. Concurrent state mutation
Ten identical requests, fired within milliseconds of each other, against an endpoint that should only allow one to succeed:
# 10 simultaneous requests, same payload, same session
POST /api/coupons/redeem HTTP/1.1
Content-Type: application/json
{ "code": "WELCOME20" }
# Expected: 1 success, 9 rejections ("already redeemed")
# Observed: 9/10 succeed within 340ms
HTTP/1.1 200 OK ×9
HTTP/1.1 409 Conflict ×1
# → discount applied 9 times instead of 12. TOCTOU — check, then race the use
A single availability check, followed immediately by a burst of requests that all act on the same (now-stale) check result:
# Step 1 — check remaining inventory
GET /api/products/842/availability HTTP/1.1
HTTP/1.1 200 OK
{ "inStock": true, "quantity": 1 }
# Step 2 — immediately fire 5 concurrent purchase requests
POST /api/products/842/purchase HTTP/1.1 ×5
{ "quantity": 1 }
# Expected: 1 success, 4 "out of stock"
# Observed: 4/5 succeed
HTTP/1.1 200 OK ×4
HTTP/1.1 409 Conflict ×1
# → 4 units sold from a stock of 13. Rate-limit bypass
A burst sent faster than the rate limiter's counting logic can catch up:
# 20 login attempts fired within a 50ms window
POST /api/auth/login HTTP/1.1 ×20
{ "email": "target@example.com", "password": "guess-N" }
# Expected: ~5 allowed, 15 × HTTP 429 (rate limit is 5/minute)
# Observed: 17/20 processed before the limiter counted the first batch
HTTP/1.1 401 Unauthorized ×17
HTTP/1.1 429 Too Many Requests ×3
# → brute-force protection bypassed by concurrency, not by volumeSafe testing methodology — without touching real transactions
Race condition testing is inherently riskier than most probes, because a successful test by definition causes the application to do something it shouldn't. The safe approach controls what that "something" is: never point concurrent bursts at endpoints that move real money or create real records you can't clean up.
Phase 1: Establish a baseline
Send a single request and record its response time. This is your control — without it, a slow network can be mistaken for a passed race condition test.
Phase 2: Controlled concurrency burst
Fire a fixed batch of identical requests (10 is usually enough) at the same endpoint, all launched together rather than sequentially:
const requests = Array(10).fill(null).map(() =>
fetch(endpoint, { method: 'POST', body, headers })
);
const responses = await Promise.all(requests);
// A race condition is indicated when:
// - success rate ≥ 80% (most requests should have been rejected)
// - total duration < 2s (no serialized locking occurred)
// - response bodies are near-identical (no per-request validation)Phase 3: TOCTOU — check, then race
Send one availability/validity check, then immediately fire several concurrent requests that act on that result. A vulnerability is confirmed when more requests succeed than the checked state should have allowed.
Phase 4: Rate-limit burst
Fire a burst larger than the documented rate limit (20 requests against a 5/minute limit, for example) within a single short window, and compare the accepted count against the limit.
What to deliberately avoid: never target checkout, withdrawal, payment, or wallet-credit endpoints with concurrency bursts on a system you don't fully control — even a "successful" test creates real financial state you'd have to unwind manually. Test against coupon codes, invite tokens, low-stakes inventory, or dedicated staging resources instead.
Where to look: the highest-risk surfaces
Coupon and promo code redemption
HighSingle-use codes are a textbook concurrent-mutation target — the discount is often applied before the 'already used' flag is written.
Inventory and limited-stock purchases
HighTOCTOU between stock check and order placement lets attackers buy more units than exist, especially on flash-sale or limited-drop pages.
Wallet credit and balance transfers
HighDouble-spend patterns: crediting a balance before debiting the source, exploitable via concurrent transfer requests.
Account creation and invite acceptance
MediumThe same invite token or referral code accepted by multiple concurrent signups can bypass single-use assumptions and unlock duplicate rewards.
Login and password-reset endpoints
MediumRate limiters protecting against brute force are common race-condition bypass targets — the limit exists but the counting isn't atomic.
Voting, rating, and review systems
Low–MediumLower business impact, but concurrent submission can inflate a single user's influence past intended limits.
What proof of exploitation requires
A single slow response is not a race condition finding. Confirmed exploitation requires showing that concurrency itself — not a missing input filter — caused an outcome the application's business logic explicitly disallows:
Endpoint performs a check-then-act sequence, or lacks visible idempotency handling on a state-changing request
A burst of identical concurrent requests produces a success rate above what the business rule should allow
Response bodies or follow-up state (balance, stock count, redemption count) show the duplicated effect actually persisted
Duplicated effect maps to real business harm — financial loss, inventory overcommit, or protection bypass — not just a duplicate log line
CVSS 3.1 scoring
Confirmed concurrent state mutation or TOCTOU on an authenticated, business-critical endpoint:
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
AV:N — Network (remotely exploitable)
AC:H — High complexity (precise timing required)
PR:L — Low privileges (authenticated user)
C:H / I:H / A:H — Full data exposure, integrity loss, and potential service impact from duplicated state changes
Score: 7.5 High
A rate-limit bypass via concurrency, with no direct data or financial impact, scores lower — typically CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N (5.3 Medium) — since the primary consequence is weakened brute-force protection rather than a confirmed breach.
Test your app for race conditions safely
Nautillo Pro runs scope-safe race condition probes — concurrent mutation, TOCTOU, and rate-limit bypass checks — while deliberately avoiding payment and checkout endpoints. Every confirmed finding includes the exact request burst, response analysis, and CVSS score.
Related articles