Race Conditions & TOCTOU: Code Review Guide
Table of Contents
1. Introduction to Race Conditions
A race condition occurs when the behavior of a system depends on the relative timing of events, and the outcome changes depending on the order in which those events execute. In security, race conditions allow attackers to exploit the gap between a security check and the action that relies on that check.
Why Race Conditions Are Dangerous
Race conditions are among the most difficult vulnerabilities to detect through traditional testing because they are non-deterministic — they don't happen every time, only when requests arrive in a specific timing window. This makes them easy to miss during development and QA, but attackers can exploit them reliably by sending many concurrent requests. Common impacts include: double-spending money or credits, bypassing rate limits, redeeming coupons multiple times, privilege escalation, and inventory overselling.
TOCTOU (Time-of-Check to Time-of-Use) is a specific class of race condition where the system checks a condition at one point in time, then uses the result at a later point — but the condition may have changed in between. If an attacker can modify the state between the check and the use, they can bypass security controls.
TOCTOU: Time-of-Check to Time-of-Use
The Vulnerability Window
A user has $100 in their account. They send two simultaneous withdrawal requests for $100 each. Both requests read the balance as $100, both confirm the balance is sufficient, and both deduct $100. What happened?
2. TOCTOU Fundamentals
Every TOCTOU vulnerability follows the same pattern: (1) Check a condition, (2) a window of time passes, and (3) Use the result of the check. If an attacker can change the state during the window, the check becomes invalid but the action proceeds.
Classic TOCTOU pattern
1# ❌ VULNERABLE: TOCTOU in every step
2
3def transfer_money(sender_id, recipient_id, amount):
4 # STEP 1: CHECK - Read the current balance
5 sender = db.query("SELECT balance FROM accounts WHERE id = %s", sender_id)
6
7 # ⏳ VULNERABILITY WINDOW ⏳
8 # Another request can execute the SAME check right now,
9 # see the SAME balance, and proceed with its own transfer.
10
11 if sender.balance < amount:
12 raise InsufficientFunds()
13
14 # STEP 2: USE - Deduct the amount
15 db.execute(
16 "UPDATE accounts SET balance = balance - %s WHERE id = %s",
17 amount, sender_id
18 )
19 db.execute(
20 "UPDATE accounts SET balance = balance + %s WHERE id = %s",
21 amount, recipient_id
22 )
23
24# ✅ SECURE: Atomic check-and-update
25def transfer_money_safe(sender_id, recipient_id, amount):
26 # Single atomic operation: check AND update in one statement
27 result = db.execute(
28 """UPDATE accounts
29 SET balance = balance - %s
30 WHERE id = %s AND balance >= %s""",
31 amount, sender_id, amount
32 )
33
34 if result.rowcount == 0:
35 raise InsufficientFunds()
36
37 db.execute(
38 "UPDATE accounts SET balance = balance + %s WHERE id = %s",
39 amount, recipient_id
40 )TOCTOU Patterns Across Domains
| Domain | Check (T.O.C.) | Window | Use (T.O.U.) | Exploit |
|---|---|---|---|---|
| Finance | SELECT balance >= $100 | ~1-5ms | UPDATE balance -= $100 | Double-spend: send 2 requests simultaneously |
| E-commerce | SELECT stock > 0 | ~1-5ms | UPDATE stock -= 1 | Oversell: buy last item multiple times |
| Coupons | SELECT coupon not used | ~1-5ms | INSERT coupon_usage | Multi-redeem: apply same coupon in parallel |
| Rate Limiting | SELECT request_count < limit | ~1-5ms | UPDATE request_count += 1 | Bypass: send burst of requests simultaneously |
| File System | access() — check file permissions | ~1-100ms | open() — open the file | Symlink swap: replace file between check and open |
| Auth | Verify password reset token valid | ~1-5ms | Mark token as used | Multi-use: reset password with same token twice |
| Voting | Check user hasn't voted | ~1-5ms | Record vote | Multi-vote: submit concurrent votes |
Which approach eliminates the TOCTOU window in a database operation?
3. Web Application Race Conditions
Web applications are particularly susceptible to race conditions because HTTP is inherently concurrent — multiple requests from the same user can arrive at the server simultaneously. Modern web servers process requests in parallel using thread pools or async event loops.
Node.js: Coupon redemption race condition
1// ❌ VULNERABLE: Race condition in coupon redemption
2app.post('/api/redeem-coupon', async (req, res) => {
3 const { couponCode, userId } = req.body;
4
5 // CHECK: Is the coupon valid and unused?
6 const coupon = await db.query(
7 'SELECT * FROM coupons WHERE code = $1 AND used = false',
8 [couponCode]
9 );
10
11 if (!coupon) {
12 return res.status(400).json({ error: 'Invalid or used coupon' });
13 }
14
15 // ⏳ WINDOW: Another request can pass the same check right now!
16
17 // USE: Mark coupon as used and apply discount
18 await db.query(
19 'UPDATE coupons SET used = true, used_by = $1 WHERE code = $2',
20 [userId, couponCode]
21 );
22
23 await db.query(
24 'UPDATE users SET credits = credits + $1 WHERE id = $2',
25 [coupon.value, userId]
26 );
27
28 res.json({ success: true, discount: coupon.value });
29});
30
31// ✅ SECURE: Atomic coupon redemption
32app.post('/api/redeem-coupon', async (req, res) => {
33 const { couponCode, userId } = req.body;
34
35 // Atomic check-and-update in a single statement
36 const result = await db.query(
37 `UPDATE coupons
38 SET used = true, used_by = $1, used_at = NOW()
39 WHERE code = $2 AND used = false
40 RETURNING value`,
41 [userId, couponCode]
42 );
43
44 if (result.rowCount === 0) {
45 return res.status(400).json({ error: 'Invalid or already used coupon' });
46 }
47
48 await db.query(
49 'UPDATE users SET credits = credits + $1 WHERE id = $2',
50 [result.rows[0].value, userId]
51 );
52
53 res.json({ success: true, discount: result.rows[0].value });
54});Python/Django: Vote manipulation race condition
1# ❌ VULNERABLE: Race condition in voting
2class VoteView(APIView):
3 def post(self, request, poll_id):
4 user = request.user
5
6 # CHECK: Has the user already voted?
7 existing_vote = Vote.objects.filter(
8 poll_id=poll_id, user=user
9 ).exists()
10
11 if existing_vote:
12 return Response(
13 {"error": "Already voted"},
14 status=400
15 )
16
17 # ⏳ WINDOW: Send 10 requests simultaneously,
18 # all pass the check before any create a vote
19
20 # USE: Record the vote
21 Vote.objects.create(
22 poll_id=poll_id,
23 user=user,
24 choice=request.data['choice']
25 )
26
27 return Response({"success": True})
28
29# ✅ SECURE: Use unique constraint + atomic operation
30class VoteView(APIView):
31 def post(self, request, poll_id):
32 user = request.user
33
34 try:
35 # Unique constraint on (poll_id, user_id) prevents duplicates
36 # The database enforces atomicity
37 Vote.objects.create(
38 poll_id=poll_id,
39 user=user,
40 choice=request.data['choice']
41 )
42 return Response({"success": True})
43 except IntegrityError:
44 # Unique constraint violation = already voted
45 return Response(
46 {"error": "Already voted"},
47 status=400
48 )HTTP/2 Single-Packet Attack
HTTP/2 multiplexing allows an attacker to send multiple requests in a single TCP packet, causing them to arrive at the server at virtually the same microsecond. This technique (described by James Kettle at PortSwigger) makes web race conditions dramatically more reliable — the timing window that was previously hit-or-miss becomes nearly guaranteed. Tools like Burp Suite's "Send group in parallel" and Turbo Intruder exploit this.
4. Database Race Conditions
Database race conditions occur in read-modify-write cycles where the application reads a value, modifies it in application code, and writes it back. Without proper isolation or locking, concurrent transactions can overwrite each other's changes.
Database isolation levels and race conditions
1-- ❌ VULNERABLE: Read-modify-write at READ COMMITTED (default in PostgreSQL)
2-- Transaction A:
3BEGIN;
4SELECT stock FROM products WHERE id = 1; -- Returns 1
5-- (stock is 1, so we proceed with purchase)
6UPDATE products SET stock = stock - 1 WHERE id = 1;
7COMMIT;
8
9-- Transaction B (concurrent):
10BEGIN;
11SELECT stock FROM products WHERE id = 1; -- Also returns 1!
12-- (stock appears to be 1, so B also proceeds)
13UPDATE products SET stock = stock - 1 WHERE id = 1;
14COMMIT;
15-- Result: stock = -1 (oversold!)
16
17-- ✅ FIX 1: SELECT ... FOR UPDATE (Pessimistic Locking)
18BEGIN;
19SELECT stock FROM products WHERE id = 1 FOR UPDATE;
20-- Row is now LOCKED — other transactions block here until we commit
21UPDATE products SET stock = stock - 1 WHERE id = 1;
22COMMIT;
23
24-- ✅ FIX 2: Atomic conditional update (no separate SELECT needed)
25UPDATE products
26SET stock = stock - 1
27WHERE id = 1 AND stock > 0
28RETURNING stock;
29-- If stock was 0, rowcount = 0, no update happened
30
31-- ✅ FIX 3: Optimistic Locking with version column
32UPDATE products
33SET stock = stock - 1, version = version + 1
34WHERE id = 1 AND version = 5;
35-- If version changed (another transaction updated it), rowcount = 0
36-- Application retries with fresh dataDatabase Locking Strategies
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Pessimistic Locking (SELECT FOR UPDATE) | Locks the row when reading; other transactions wait | Guaranteed consistency, simple to implement | Reduces throughput, risk of deadlocks |
| Optimistic Locking (version column) | Read version, update only if version unchanged, retry on conflict | High throughput, no lock contention | Requires retry logic, can starve under high contention |
| Atomic Conditional Update | Single UPDATE with WHERE clause as the check | Simplest, no extra reads, database-enforced | Limited to simple conditions; complex logic may need transactions |
| SERIALIZABLE Isolation | Database detects conflicts and aborts one transaction | Strongest guarantee, automatic conflict detection | Performance impact, application must handle serialization errors |
| Advisory Locks | Application-level named locks in the database | Flexible, can lock across tables/concepts | Manual lock management, risk of forgetting to unlock |
| Unique Constraints | Database prevents duplicate inserts atomically | Very fast, zero overhead, cannot be bypassed | Only works for preventing duplicate inserts |
Your e-commerce application uses Redis to cache inventory counts. When a purchase is made, the app checks Redis for stock > 0, then updates both Redis and PostgreSQL. Where is the race condition?
5. Payment & Financial Race Conditions
Payment and financial systems are the highest-value targets for race condition exploitation. Attackers specifically target withdrawal endpoints, gift card redemptions, referral bonuses, and any operation that converts a check into monetary value.
Gift card / wallet top-up race condition
1// ❌ VULNERABLE: Gift card redemption with race condition
2app.post('/api/redeem-gift-card', async (req, res) => {
3 const { cardCode, userId } = req.body;
4
5 const card = await db.query(
6 'SELECT * FROM gift_cards WHERE code = $1',
7 [cardCode]
8 );
9
10 if (!card || card.redeemed) {
11 return res.status(400).json({ error: 'Invalid or redeemed card' });
12 }
13
14 // ⏳ Attacker sends 50 concurrent requests with the same card code
15 // All 50 pass the check above (card.redeemed is still false)
16
17 // Mark card as redeemed
18 await db.query(
19 'UPDATE gift_cards SET redeemed = true, redeemed_by = $1 WHERE code = $2',
20 [userId, cardCode]
21 );
22
23 // Credit the user — this executes 50 times!
24 await db.query(
25 'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2',
26 [card.value, userId]
27 );
28
29 // A $50 gift card just credited $2,500 (50 × $50)
30 res.json({ success: true, credited: card.value });
31});
32
33// ✅ SECURE: Atomic redemption using transaction + row locking
34app.post('/api/redeem-gift-card', async (req, res) => {
35 const { cardCode, userId } = req.body;
36
37 const client = await pool.connect();
38 try {
39 await client.query('BEGIN');
40
41 // Lock the gift card row — other transactions WAIT here
42 const card = await client.query(
43 'SELECT * FROM gift_cards WHERE code = $1 FOR UPDATE',
44 [cardCode]
45 );
46
47 if (!card.rows[0] || card.rows[0].redeemed) {
48 await client.query('ROLLBACK');
49 return res.status(400).json({ error: 'Invalid or redeemed card' });
50 }
51
52 await client.query(
53 'UPDATE gift_cards SET redeemed = true, redeemed_by = $1 WHERE code = $2',
54 [userId, cardCode]
55 );
56
57 await client.query(
58 'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2',
59 [card.rows[0].value, userId]
60 );
61
62 await client.query('COMMIT');
63 res.json({ success: true, credited: card.rows[0].value });
64 } catch (err) {
65 await client.query('ROLLBACK');
66 throw err;
67 } finally {
68 client.release();
69 }
70});Real Attack: Starbucks Gift Card Race
In a well-known bug bounty report, a researcher discovered that Starbucks' gift card transfer feature was vulnerable to a race condition. By sending simultaneous transfer requests, they could transfer the same balance to multiple recipients — effectively duplicating money. The same pattern applies to any system that checks a balance, then deducts: withdrawal endpoints, peer-to-peer transfers, reward point redemptions, in-app currency purchases.
Idempotency keys to prevent duplicate processing
1# ✅ SECURE: Idempotency key prevents duplicate request processing
2@app.post('/api/transfer')
3async def transfer(request: TransferRequest):
4 idempotency_key = request.headers.get('Idempotency-Key')
5
6 if not idempotency_key:
7 raise HTTPException(400, "Idempotency-Key header required")
8
9 # Attempt to insert the idempotency key (unique constraint)
10 try:
11 result = await db.execute(
12 """INSERT INTO idempotency_keys (key, status, created_at)
13 VALUES ($1, 'processing', NOW())""",
14 idempotency_key
15 )
16 except UniqueViolationError:
17 # Key already exists — return the cached response
18 cached = await db.fetchrow(
19 "SELECT response FROM idempotency_keys WHERE key = $1",
20 idempotency_key
21 )
22 return JSONResponse(content=json.loads(cached['response']))
23
24 # Process the transfer (only one request reaches here per key)
25 try:
26 response = await process_transfer(request)
27 await db.execute(
28 """UPDATE idempotency_keys
29 SET status = 'completed', response = $1
30 WHERE key = $2""",
31 json.dumps(response), idempotency_key
32 )
33 return response
34 except Exception as e:
35 await db.execute(
36 "DELETE FROM idempotency_keys WHERE key = $1",
37 idempotency_key
38 )
39 raise6. File System TOCTOU
File system TOCTOU vulnerabilities are the original race condition class. They occur when a program checks a file's properties (existence, permissions, ownership) and then operates on the file — but an attacker swaps the file between the check and the operation.
C: Classic file system TOCTOU
1/* ❌ VULNERABLE: TOCTOU in file access check */
2#include <stdio.h>
3#include <unistd.h>
4
5void process_file(const char *filename) {
6 /* CHECK: Is the file safe to read? */
7 if (access(filename, R_OK) == 0) {
8 /* ⏳ WINDOW: Attacker replaces file with symlink!
9 *
10 * Attacker runs in a loop:
11 * ln -sf /etc/shadow /tmp/data.txt
12 *
13 * Between access() and fopen(), the attacker swaps
14 * /tmp/data.txt with a symlink to /etc/shadow
15 */
16
17 /* USE: Open and read the file */
18 FILE *f = fopen(filename, "r");
19 /* Now reading /etc/shadow instead of the intended file! */
20 char buf[4096];
21 fread(buf, 1, sizeof(buf), f);
22 fclose(f);
23 }
24}
25
26/* ✅ SECURE: Open first, then check using the file descriptor */
27void process_file_safe(const char *filename) {
28 /* Open the file first — get a file descriptor */
29 int fd = open(filename, O_RDONLY | O_NOFOLLOW);
30 if (fd < 0) return;
31
32 /* Check permissions on the file descriptor (not the path) */
33 struct stat st;
34 if (fstat(fd, &st) < 0) {
35 close(fd);
36 return;
37 }
38
39 /* Verify it's a regular file, not a symlink/device */
40 if (!S_ISREG(st.st_mode)) {
41 close(fd);
42 return;
43 }
44
45 /* Now read from the verified file descriptor */
46 /* The fd still points to the same file, regardless of
47 any path changes the attacker makes */
48 char buf[4096];
49 read(fd, buf, sizeof(buf));
50 close(fd);
51}Python: Temp file TOCTOU
1import os
2import tempfile
3
4# ❌ VULNERABLE: Predictable temp file with race condition
5def write_temp_data(data):
6 tmpfile = '/tmp/myapp_data.txt'
7
8 # CHECK: Does the file exist?
9 if not os.path.exists(tmpfile):
10 # ⏳ WINDOW: Attacker creates a symlink:
11 # ln -s /etc/crontab /tmp/myapp_data.txt
12
13 # USE: Write to the file (now writing to /etc/crontab!)
14 with open(tmpfile, 'w') as f:
15 f.write(data)
16
17# ✅ SECURE: Use mkstemp for atomic creation with unique names
18def write_temp_data_safe(data):
19 # mkstemp atomically creates AND opens a unique file
20 # O_EXCL flag means it fails if the file already exists
21 fd, path = tempfile.mkstemp(prefix='myapp_', suffix='.txt')
22 try:
23 os.write(fd, data.encode())
24 finally:
25 os.close(fd)
26 return path
27
28# ✅ SECURE: Use tempfile context manager
29def write_temp_data_safest(data):
30 with tempfile.NamedTemporaryFile(
31 mode='w',
32 prefix='myapp_',
33 delete=False # Keep file after closing
34 ) as f:
35 f.write(data)
36 return f.nameA web application saves uploaded files to /tmp/uploads/ using the user-provided filename, checking if the path stays within the upload directory. Where is the TOCTOU vulnerability?
7. Detection During Code Review
Race conditions follow recognizable patterns in code. During code review, look for any sequence where a value is read, a decision is made based on that value, and then an action is taken — without atomicity guarantees between these steps.
Code Review Detection Patterns
| Pattern | Code Smell | Risk Level | Fix |
|---|---|---|---|
| SELECT then UPDATE | Read a value, check it in application code, then update based on the check | Critical | Atomic conditional UPDATE with WHERE clause |
| Check-then-insert | Check if record exists, then insert if not | High | Unique constraint + INSERT ON CONFLICT or try/catch IntegrityError |
| Read-modify-write counter | Read count, increment in code, write back | High | Atomic INCREMENT (UPDATE count = count + 1) |
| Token/code validation | SELECT token → check valid → UPDATE used=true | Critical | Atomic UPDATE WHERE used=false RETURNING * |
| Balance check → deduction | SELECT balance → if sufficient → UPDATE balance | Critical | UPDATE WHERE balance >= amount or SELECT FOR UPDATE |
| File exists check → file open | os.path.exists() → open() | Medium | Open with O_CREAT|O_EXCL, use file descriptors |
| Permission check → action | access() → open() or stat() → read() | Medium | Open first, fstat() on file descriptor |
| Cache check → DB update | Check Redis/cache → update database | High | Use Redis atomic operations (WATCH/MULTI or Lua scripts) |
Grep patterns for finding race conditions
1# Find check-then-act patterns in SQL queries
2# Look for SELECT followed by UPDATE/INSERT on the same table
3
4# Python/Django ORM patterns to flag:
5.exists() # Followed by .create() → race condition
6.filter().first() # Followed by .save() or .update() → race condition
7.get_or_create() # This is safe (atomic)! But custom implementations may not be
8
9# Node.js patterns to flag:
10findOne() # Followed by save() or updateOne()
11find().count() # Followed by create() based on count
12
13# Java patterns to flag:
14.find() # Followed by .save() in Spring Data
15entityManager.find() # Followed by entityManager.merge()
16
17# File system patterns to flag:
18os.path.exists # Followed by open()
19os.access # Followed by open()
20access() # Followed by open() (C/C++)
21stat() # Followed by open() (C/C++)
22File.exists() # Followed by new FileWriter() (Java)
23
24# Redis patterns to flag:
25GET key # Followed by SET key (not atomic)
26EXISTS key # Followed by SET key (not atomic)
27# Safe: INCR, DECR, SETNX, GETSET, Lua scriptsKey Question During Code Review
For any endpoint that modifies state, ask: "What happens if this exact request is sent 100 times simultaneously?" If the answer involves any form of "check a value then update based on it," there's likely a race condition. Stateful operations (balance changes, coupon redemptions, vote submissions, inventory updates) must be atomic.