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+httpxin 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.