Race Conditions in Web Applications: How TOCTOU Vulnerabilities Enable Double-Spend and Privilege Escalation

Race conditions and TOCTOU (time-of-check to time-of-use) vulnerabilities exploit the gap between a validation check and the action that depends on it — enabling double-spend attacks on financial operations, parallel account creation bypasses, and privilege escalation. This guide covers the attack patterns, real-world scenarios across REST APIs and databases, and the application-level and database-level fixes that close the race window.

Race conditions are one of the most underreported vulnerability classes in web security. They’re difficult to discover through normal manual testing, don’t show up in most SAST scans, and require deliberate concurrent request tooling to reproduce reliably. But the business impact can be severe — double-spend attacks on financial operations, coupon/promo code reuse, and concurrent registration bypasses have cost real money in real applications.

TOCTOU (time-of-check to time-of-use) is the specific pattern: an application checks a condition, then acts on it, assuming the condition hasn’t changed between the check and the action. Concurrent requests can invalidate that assumption.

The Classic Vulnerable Pattern

Consider a balance check before a transfer:

# Vulnerable Python/Flask example
@app.route('/transfer', methods=['POST'])
def transfer():
    user_id = current_user.id
    amount = request.json['amount']
    
    # CHECK: does the user have enough balance?
    user = db.query(User).filter_by(id=user_id).first()
    if user.balance < amount:
        return {'error': 'Insufficient funds'}, 400
    
    # USE: deduct the balance (race window is here)
    user.balance -= amount
    db.commit()
    
    process_transfer(user_id, amount)
    return {'status': 'ok'}

If two concurrent requests both reach the balance check before either has committed the deduction, both pass. The account ends up with a negative balance or the transaction is processed twice.

Exploiting this manually:

import httpx, asyncio

async def exploit_race():
    async with httpx.AsyncClient() as client:
        # Fire 10 identical transfer requests simultaneously
        tasks = [
            client.post('/transfer', json={'amount': 100}, 
                       headers={'Authorization': f'Bearer {TOKEN}'})
            for _ in range(10)
        ]
        results = await asyncio.gather(*tasks)
        for r in results:
            print(r.status_code, r.json())

asyncio.run(exploit_race())

In a vulnerable application, most of these will return {'status': 'ok'} even though the balance only supports one.

Common High-Impact Scenarios

Coupon/promo code redemption: Check if code is unused → mark as used → apply discount. Concurrent requests can redeem the same code multiple times before the first write commits.

Referral bonus claims: Check if referral is pending → mark as claimed → credit account. Same race window.

Limited item purchasing: Check if item is in stock → decrement inventory → create order. Classic overselling vector.

Account linking / OAuth registration: Check if email is already registered → create account. Concurrent registrations with the same email can create duplicate accounts, sometimes bypassing email verification flows.

Role/permission assignment: Check if user already has role → assign role. Less common but enables privilege duplication.

Fix 1: Database-Level Atomic Operations

The most reliable fix for most scenarios is removing the separate check entirely by using atomic database operations:

from sqlalchemy import text

def transfer_atomic(user_id, amount):
    # Atomic UPDATE that only succeeds if balance is sufficient
    result = db.execute(
        text("""
            UPDATE users 
            SET balance = balance - :amount
            WHERE id = :user_id AND balance >= :amount
        """),
        {'amount': amount, 'user_id': user_id}
    )
    db.commit()
    
    if result.rowcount == 0:
        raise InsufficientFundsError()
    
    process_transfer(user_id, amount)

The WHERE balance >= :amount predicate means the update only applies if the condition is still true at write time. The database guarantees this is atomic — no concurrent request can slip in between the check and the update.

For unique constraint races (duplicate email registration), a UNIQUE constraint at the database level plus catching the IntegrityError is the right approach:

from sqlalchemy.exc import IntegrityError

def create_user(email, password_hash):
    try:
        user = User(email=email, password_hash=password_hash)
        db.add(user)
        db.commit()
        return user
    except IntegrityError:
        db.rollback()
        raise EmailAlreadyExistsError()

Fix 2: Database-Level Locking

For more complex operations that can’t be reduced to a single atomic statement, use SELECT FOR UPDATE to hold a row lock for the duration of the transaction:

# Python / SQLAlchemy with pessimistic locking
from sqlalchemy import select
from sqlalchemy.orm import Session

def transfer_with_lock(db: Session, user_id: int, amount: float):
    with db.begin():
        # Lock the row for the duration of this transaction
        user = db.execute(
            select(User).where(User.id == user_id).with_for_update()
        ).scalar_one()
        
        if user.balance < amount:
            raise InsufficientFundsError()
        
        user.balance -= amount
        # Transaction commits here; lock is released

Any concurrent request attempting the same SELECT FOR UPDATE will block until the first transaction commits. This serialises access to the critical section without requiring application-level locking.

Fix 3: Redis Distributed Locks for Non-Database Resources

When the critical section involves external state (sending an email, calling a payment processor, consuming a coupon in a cache), use a distributed lock:

// Node.js with ioredis
const Redis = require('ioredis');
const redis = new Redis();

async function redeemCoupon(userId, couponCode) {
  const lockKey = `coupon_lock:${couponCode}`;
  const lockValue = `${userId}:${Date.now()}`;
  
  // Attempt to acquire lock (NX = only if not exists, EX = expire after 5s)
  const acquired = await redis.set(lockKey, lockValue, 'NX', 'EX', 5);
  
  if (!acquired) {
    throw new Error('Coupon already being processed');
  }
  
  try {
    // Check and mark in a single operation now that we hold the lock
    const coupon = await Coupon.findOne({ code: couponCode, usedBy: null });
    if (!coupon) throw new Error('Coupon invalid or already used');
    
    await coupon.update({ usedBy: userId, usedAt: new Date() });
    await applyDiscount(userId, coupon.discountAmount);
  } finally {
    // Release lock only if we still own it (Lua script for atomicity)
    const luaScript = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;
    await redis.eval(luaScript, 1, lockKey, lockValue);
  }
}

Fix 4: Idempotency Keys

For operations where the client might retry (payment processors, transfer APIs), idempotency keys prevent duplicate processing regardless of race conditions:

// Go — idempotency key check before processing
func (h *TransferHandler) HandleTransfer(w http.ResponseWriter, r *http.Request) {
    idempotencyKey := r.Header.Get("Idempotency-Key")
    if idempotencyKey == "" {
        http.Error(w, "Idempotency-Key header required", http.StatusBadRequest)
        return
    }
    
    // Check if this key was already processed
    existing, err := h.store.GetByIdempotencyKey(idempotencyKey)
    if err == nil {
        // Already processed — return the cached response
        json.NewEncoder(w).Encode(existing.Response)
        return
    }
    
    // Process and store result atomically
    result := h.processTransfer(r)
    h.store.StoreWithIdempotencyKey(idempotencyKey, result)
    json.NewEncoder(w).Encode(result)
}

Testing for Race Conditions

Standard tools for discovering race windows:

  • Turbo Intruder (Burp Suite extension): Sends requests with precise timing control using HTTP/2 single-packet attacks, which reduces network jitter and makes race windows easier to hit
  • asyncio + httpx in Python: Fast enough for most test scenarios with a few dozen concurrent requests
  • wrk / hey: HTTP load testing tools useful for sustained concurrent request patterns

Burp Suite’s HTTP/2 single-packet attack technique (sending multiple requests in one TCP segment) is particularly effective at hitting tight race windows that network latency would normally obscure.

Race conditions won’t show up in a standard security scan. If your application handles financial operations, coupon redemption, or any action that depends on a check-then-act pattern, explicit concurrent testing during security review is essential.