Security concepts

What is a race condition attack?

A race condition attack exploits a race window: two tasks operate on the same data at once, and the intended order is not enforced. A one-time coupon or wallet withdrawal that checks, then writes, is the usual request flow. Atomic updates and unique constraints close the window. Rate limiting on the business route makes concurrent probes expensive but does not replace the write.

8 min read
In short: A race condition attack exploits a race window: two tasks operate on the same data at once, and the intended order is not enforced. A one-time coupon or wallet withdrawal that checks, then writes, is the usual request flow. Atomic updates and unique constraints close the window. Rate limiting on the business route makes concurrent probes expensive but does not replace the write.

What is a race condition attack?

A race condition attack exploits a race window: a brief interval where two or more tasks operate on the same data at the same time, and the system's intended outcome depends on a specific order that the code does not enforce. The application is designed for sequential work. Concurrent requests collide. The collision is the bug. Forcing that collision is the attack.

Race windows often last milliseconds. They appear wherever you check a value, then act on it, without making that pair atomic. Classic cases are one-time coupons, balance withdrawals, inventory holds, and password-reset tokens. API abuse covers the business-flow version of the same problem: an allowed operation repeated or coordinated until the invariant breaks.

In September 2012 James Zhong abused a withdrawal race on Silk Road. Rapid, duplicated Bitcoin withdrawals extracted about 50,000 BTC. That is the same check-then-act pattern you still ship on a checkout route.

How does a coupon or double-spend race work?

A one-time coupon is the cleanest request-flow to hold in your head. The intended sequence is:

  1. The client POSTs /checkout with coupon=SAVE20.
  2. The server reads the redemption row: redeemed = false.
  3. The server applies the discount and writes redeemed = true.
  4. A later request sees redeemed = true and is rejected.

Two requests that arrive inside the race window both pass step 2 before either write lands:

TimeRequest ARequest BStored state
t0Read coupon: not redeemedRead coupon: not redeemedredeemed = false
t1Apply 20% discountApply 20% discountredeemed = false
t2Write redeemedWrite redeemedredeemed = true
t3200 OK200 OKCoupon used twice

The third request, arriving after the write, is rejected. The first two both looked valid. That is a limit-overrun attack: an imposed application limit (one redemption) is exceeded because the check and the update are not one operation.

A wallet or points balance is the same flow with a number instead of a flag. Two withdrawals read balance = 100, both subtract 100, both write balance = 0. You paid out 200 from an account that held 100. Call that a double-spend. The fix is not a longer sleep or a "please don't click twice" button. The fix is an atomic update that only succeeds for one writer:

UPDATE coupons
SET redeemed = true, redeemed_by = $user_id, redeemed_at = now()
WHERE code = $code AND redeemed = false;

If the statement reports zero rows updated, the coupon was already taken. Pair that with a unique constraint on code (and on any idempotency key you accept from the client) so a replay cannot insert a second redemption row.

What other race condition attacks exist?

Limit overrun is the common case. Two other shapes show up in reviews.

Single-endpoint. Many handlers key state on a session or object id. If two requests in the same session submit different usernames at once, both threads write the same row. A password-reset flow that stores {sessionId, username, email, token} can end with the victim's username and the attacker's email on the same token. The reset mail goes to the attacker. The collision is on the session key, not on a coupon flag.

Multi-endpoint. A process spans two routes that share state. A ride-share /request notifies every free driver; /accept pairs the first one. If two accept calls commit inside the same window, two drivers are assigned to one ride. The invariant ("exactly one driver") is not enforced at write time.

Any control that assumes "we already checked that" without holding the row, a lock, or a compare-and-swap is in this family. Static analysis (SonarQube, Coverity) and runtime checks for corrupted state help you find the windows. They do not close them.

How do you prevent race condition vulnerabilities?

Review every path that reads a shared resource and then writes it. Prefer a single database statement or an atomic primitive over an in-process lock when the writers can run on more than one machine. An in-memory mutex only serializes one process.

When you do need in-process synchronization, most languages give you the same four tools. A mutex lets one thread through. A semaphore lets N through. A monitor pairs a mutex with a condition so a consumer can wait until a producer signals. An atomic operation updates a single value without a lock.

The following TypeScript mutex serializes increments of a shared counter:

class Mutex {
private isLocked = false;
private waiting: (() => void)[] = [];
async lock(): Promise<void> {
while (this.isLocked) {
await new Promise<void>((resolve) => this.waiting.push(resolve));
}
this.isLocked = true;
}
unlock(): void {
if (!this.isLocked) {
throw new Error("Mutex is not locked");
}
this.isLocked = false;
const next = this.waiting.shift();
if (next) next();
}
}
const mutex = new Mutex();
let sharedCounter = 0;
async function increment() {
for (let i = 0; i < 100000; i++) {
await mutex.lock();
sharedCounter++;
mutex.unlock();
}
}
await Promise.all([increment(), increment()]);
console.log(`Final counter value: ${sharedCounter}`);

The same mutex in Python:

import threading
shared_counter = 0
mutex = threading.Lock()
def increment():
global shared_counter
for _ in range(100000):
mutex.acquire()
shared_counter += 1
mutex.release()
threads = [threading.Thread(target=increment) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(f"Final counter value: {shared_counter}")

And in Go:

package main
import (
"fmt"
"sync"
)
var (
wg sync.WaitGroup
mutex sync.Mutex
sharedCounter int
)
func increment() {
for i := 0; i < 100000; i++ {
mutex.Lock()
sharedCounter++
mutex.Unlock()
}
wg.Done()
}
func main() {
wg.Add(2)
go increment()
go increment()
wg.Wait()
fmt.Printf("Final counter value: %d\n", sharedCounter)
}

A counting semaphore is the same idea with a budget. Use it when a pool (two database connections, two file handles) can be shared, but not by everyone at once:

class Semaphore {
private tokens: number;
private waiting: (() => void)[] = [];
constructor(tokens: number) {
this.tokens = tokens;
}
async acquire(): Promise<void> {
if (this.tokens > 0) {
this.tokens--;
return;
}
await new Promise<void>((resolve) => this.waiting.push(resolve));
}
release(): void {
this.tokens++;
const next = this.waiting.shift();
if (next) next();
}
}
const semaphore = new Semaphore(2);
import threading
import time
semaphore = threading.Semaphore(2)
def access_resource(thread_id):
print(f"Thread {thread_id} is waiting to access the resource.")
semaphore.acquire()
print(f"Thread {thread_id} has accessed the resource.")
time.sleep(1)
print(f"Thread {thread_id} is releasing the resource.")
semaphore.release()
func accessResource(id int, semaphore chan struct{}, wg *sync.WaitGroup) {
fmt.Printf("Thread %d is waiting to access the resource.\n", id)
semaphore <- struct{}{}
fmt.Printf("Thread %d has accessed the resource.\n", id)
time.Sleep(1 * time.Second)
fmt.Printf("Thread %d is releasing the resource.\n", id)
<-semaphore
wg.Done()
}
func main() {
var wg sync.WaitGroup
semaphore := make(chan struct{}, 2)
for i := 0; i < 5; i++ {
wg.Add(1)
go accessResource(i, semaphore, &wg)
}
wg.Wait()
}

A monitor adds a condition variable so a consumer can wait until a producer has work. Python's threading.Condition and Go's sync.Cond are the usual forms. Use a monitor when "the lock is free" is not enough and you also need "the data is ready."

Atomic operations skip the lock for a single integer update. TypeScript's Atomics.add on a SharedArrayBuffer and Go's sync/atomic are the primitives. Python does not ship an equivalent integer atomic in the standard library; use a lock, or multiprocessing.Value across processes. Do not treat a read / increment / write as atomic just because it fits on one line.

const buffer = new SharedArrayBuffer(4);
const sharedCounter = new Int32Array(buffer);
async function increment() {
for (let i = 0; i < 100000; i++) {
Atomics.add(sharedCounter, 0, 1);
}
}
await Promise.all([increment(), increment()]);
console.log(`Final counter value: ${sharedCounter[0]}`);
var sharedCounter int32
func increment(wg *sync.WaitGroup) {
for i := 0; i < 100000; i++ {
atomic.AddInt32(&sharedCounter, 1)
}
wg.Done()
}

Locks have costs. Throughput drops because waiters block. Two threads that each hold one lock and then request the other can deadlock and wait forever. Prefer the database's compare-and-swap or a unique constraint when the resource lives in a store that every replica already shares.

Can rate limiting stop concurrent-request abuse?

Rate limiting does not close a race window. An atomic write does. A tight per-identity limit on the redeem or withdraw route still helps: it makes it expensive to spray hundreds of parallel requests at the same object, which is how most limit-overrun probes start.

Put the limit on the business operation, not on "all HTTP." A token bucket keyed on userId (or on the coupon code plus user) with a small capacity is enough to cut the fan-out. Compare algorithms in the rate limiting guide before you pick one.

import arcjet, { tokenBucket } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["userId"],
rules: [
tokenBucket({
mode: "LIVE",
refillRate: 2,
interval: 60,
capacity: 4,
}),
],
});
export async function POST(req: Request) {
const userId = req.headers.get("x-user-id") ?? "anonymous";
const decision = await aj.protect(req, { userId, requested: 1 });
if (decision.isDenied() && decision.reason.isRateLimit()) {
return new Response("Too many redemption attempts.", { status: 429 });
}
// Atomic UPDATE ... WHERE redeemed = false lives here.
}

The same limit in Python. Durations are integer seconds here; there is no "1m" string form:

from arcjet import Mode, arcjet, token_bucket
aj = arcjet(
key=ARCJET_KEY,
characteristics=["userId"],
rules=[
token_bucket(mode=Mode.LIVE, refill_rate=2, interval=60, capacity=4),
],
)
async def redeem(request, user_id: str) -> bool:
decision = await aj.protect(
request,
requested=1,
characteristics={"userId": user_id},
)
if decision.is_denied() and decision.reason_v2.type == "RATE_LIMIT":
return False
# Atomic UPDATE ... WHERE redeemed = false lives here.
return True

And in Go, where the interval is a time.Duration. A bare 60 there means 60 nanoseconds, not 60 seconds:

var aj, _ = arcjet.NewClient(arcjet.Config{
Characteristics: []string{"userId"},
Rules: []arcjet.Rule{
arcjet.TokenBucket(arcjet.TokenBucketOptions{
Mode: arcjet.ModeLive,
RefillRate: 2,
Interval: time.Minute,
Capacity: 4,
}),
},
})
func redeem(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
if userID == "" {
userID = "anonymous"
}
decision, err := aj.Protect(r.Context(), r,
arcjet.WithCharacteristic("userId", userID),
arcjet.WithRequested(1),
)
if err != nil {
slog.Warn("arcjet: protect", "err", err)
}
if decision.IsDenied() && decision.Reason.IsRateLimit() {
http.Error(w, "Too many redemption attempts.", http.StatusTooManyRequests)
return
}
// Atomic UPDATE ... WHERE redeemed = false lives here.
}

None of these three close the race. They cap how many attempts an identity gets into the window where the race exists, which buys the atomic write room to be the thing that decides.

If two requests still land inside the window, the limiter may allow both. The UPDATE ... WHERE redeemed = false (or the unique constraint) is what makes the second write a no-op. Use both: the limiter shrinks the probe, the atomic write preserves the invariant.

What should you review before you ship a shared write?

  1. Name the invariant in one sentence ("a coupon is redeemed at most once").
  2. Find the check-then-act pair. If two requests can both pass the check, you have a window.
  3. Close it in the store that every replica shares: a conditional update, a unique index, SELECT FOR UPDATE, or an idempotency key with a uniqueness constraint.
  4. Use in-process locks only for state that never leaves that process.
  5. Add a regression test that fires two concurrent requests and asserts one success.
  6. Rate-limit the route so a script cannot cheaply widen the window.

Concurrency makes handlers faster. It also makes "we already checked that" a lie unless you reserved the row. Treat every shared write as a race until the test proves it is not.

Frequently asked questions

What is a race condition attack?

It is an attack that forces two or more requests to operate on the same data inside a race window, so a check-then-act control (coupon used once, balance sufficient) is applied more than once.

How do you stop a coupon being redeemed twice?

Make the check and the write one operation, such as UPDATE ... WHERE redeemed = false, and enforce a unique constraint. If zero rows update, the coupon is already taken.

Does rate limiting fix a race condition?

No. A per-user limit on the redeem route makes parallel probes expensive. The atomic write is what preserves the invariant when two requests still land in the window.

Are in-process mutexes enough in production?

Only for state that never leaves that process. Writers on more than one machine need a store-level compare-and-swap, a row lock, or a unique index.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.