0x55aa
Back to Blog

Race Conditions: The Hacker's Secret Weapon to Double-Spend Your Money 🏎️

|
7 min read

Race Conditions: The Hacker's Secret Weapon to Double-Spend Your Money 🏎️

Imagine you have $10 in your account and you buy two $9 items simultaneously. Common sense says you can only afford one, right?

Your server disagrees.

In the half-millisecond between "check the balance" and "deduct the money," two requests are already racing to the finish line — and they both think there's enough money. Congratulations, you just sold $18 worth of goods for $10. And the hacker does this all day. ⚡

Welcome to race conditions — the vulnerability that breaks payment systems, voids your rate limits, and lets hackers claim your one-per-customer promo code 50 times.

What's a Race Condition? 🤔

A race condition happens when two operations read shared state, make a decision based on it, then both act on that state before either one has finished updating it.

The classic pattern is TOCTOU: Time of Check to Time of Use.

Thread A: CHECK balance ($10)  →  (pause)  →  USE balance (deduct $9)
Thread B:              CHECK balance ($10)  →  USE balance (deduct $9)

Thread A and B both see $10. Both decide "yes, enough funds." Both deduct $9. Your account now has -$8. The hacker just got two items for the price of one. 🎉 (For them. Not for you.)

This isn't theoretical. It's how attackers have stolen millions from fintech apps, drained gift card balances, and bypassed rate limiters. Modern web servers handle thousands of concurrent requests — you're practically setting the table for this attack.

Real Attack Scenario #1: Coupon Code Abuse 🎟️

You ship a coupon code: SAVE50 — valid once per user. Here's the backend:

// Express.js — DO NOT SHIP THIS
app.post('/apply-coupon', async (req, res) => {
  const { userId, code } = req.body;

  // Step 1: Check if coupon was used
  const usage = await db.query(
    'SELECT * FROM coupon_usage WHERE user_id = ? AND code = ?',
    [userId, code]
  );

  if (usage.length > 0) {
    return res.status(400).json({ error: 'Coupon already used' });
  }

  // Step 2: Apply discount  ← ATTACKER FIRES 50 REQUESTS RIGHT HERE
  await db.query(
    'INSERT INTO coupon_usage (user_id, code) VALUES (?, ?)',
    [userId, code]
  );

  await applyDiscount(userId, 50);
  res.json({ success: true });
});

The attacker writes a tiny script that fires 50 requests at the exact same millisecond. Every request passes the "already used" check before any of them insert the usage row. Result: SAVE50 applied 50 times. 🎪

Real world: Starbucks had a race condition in their gift card system that let researchers transfer the same balance to multiple cards simultaneously. Imagine that at scale.

Real Attack Scenario #2: Blowing Past Rate Limits 🚦

You're protecting a password-reset endpoint — max 3 attempts per hour:

# Flask — also vulnerable
@app.route('/reset-password', methods=['POST'])
def reset_password():
    email = request.json['email']
    
    # Check rate limit
    attempts = redis.get(f'reset_attempts:{email}') or 0
    if int(attempts) >= 3:
        return jsonify(error='Too many attempts'), 429
    
    # Increment counter  ← The gap a race condition lives in
    redis.incr(f'reset_attempts:{email}')
    redis.expire(f'reset_attempts:{email}', 3600)
    
    send_reset_email(email)
    return jsonify(success=True)

The attacker fires 20 concurrent requests. All 20 read attempts = 0. All 20 pass the check. All 20 send reset emails. Your rate limiter just became decoration. 🎨

How to Actually Fix This 🛠️

Fix #1: Database Transactions with Locking

The coupon bug needs the check and insert to happen atomically:

// Safe version with SELECT FOR UPDATE
app.post('/apply-coupon', async (req, res) => {
  const { userId, code } = req.body;

  await db.transaction(async (trx) => {
    // Lock the row — other transactions must wait
    const usage = await trx.raw(
      'SELECT * FROM coupon_usage WHERE user_id = ? AND code = ? FOR UPDATE',
      [userId, code]
    );

    if (usage[0].length > 0) {
      throw new Error('Coupon already used');
    }

    await trx('coupon_usage').insert({ user_id: userId, code });
    await applyDiscount(userId, 50, trx);
  });

  res.json({ success: true });
});

SELECT FOR UPDATE tells the database: "Lock this row. Make every other request queue up and wait." Now your 50 concurrent requests execute one at a time. Only the first succeeds; the rest get "already used." 🔒

Fix #2: Atomic Operations in Redis

For the rate limit, ditch the check-then-increment pattern entirely. Use Redis's atomic INCR and check after:

@app.route('/reset-password', methods=['POST'])
def reset_password():
    email = request.json['email']
    key = f'reset_attempts:{email}'
    
    # INCR is atomic — no race condition possible
    attempts = redis.incr(key)
    
    if attempts == 1:
        # First attempt — set expiry
        redis.expire(key, 3600)
    
    if attempts > 3:
        return jsonify(error='Too many attempts'), 429
    
    send_reset_email(email)
    return jsonify(success=True)

INCR in Redis is a single atomic operation — it reads and writes in one step. No gap for another request to sneak through. Even if 50 requests arrive simultaneously, they're serialized by Redis into a queue. Request #4 onwards gets blocked. ✅

Fix #3: Idempotency Keys for Payments

For financial operations, use idempotency keys — a unique token the client generates per operation:

app.post('/transfer', async (req, res) => {
  const { fromAccount, toAccount, amount, idempotencyKey } = req.body;

  // Try to claim the idempotency key atomically
  const inserted = await db('idempotency_keys')
    .insert({ key: idempotencyKey, created_at: new Date() })
    .onConflict('key')
    .ignore(); // Silently fail if key exists

  if (inserted.rowCount === 0) {
    // Key already used — this is a duplicate request
    return res.status(200).json({ message: 'Transfer already processed' });
  }

  // Safe to process — key is now locked to this request
  await processTransfer(fromAccount, toAccount, amount);
  res.json({ success: true });
});

The unique constraint on idempotency_keys makes duplicate detection a database-level guarantee, not application logic. Even if 100 requests arrive with the same key, exactly one wins. This is how Stripe, PayPal, and every serious payments API handles retries safely. 💳

The Security Checklist for Race Conditions 📋

  • Any "check then act" pattern is a potential race condition — audit all of them
  • Use SELECT FOR UPDATE / FOR SHARE when checking and modifying the same row
  • Wrap related reads and writes in a single database transaction
  • Use atomic operations (INCR, SETNX, GETSET) for counters in Redis/Memcached
  • Add unique constraints at the DB level (not just app-level checks)
  • Use idempotency keys for all financial operations
  • Load-test concurrent requests against your payment and coupon endpoints
  • Consider optimistic locking (version numbers) for high-contention data

Quick Gut-Check Test 🔍

Look through your codebase for this exact pattern:

1. Read a value from DB/cache
2. Make a decision based on it
3. Write back to DB/cache

If those three steps aren't wrapped in a transaction or atomic operation, you probably have a race condition. Common hotspots: coupon systems, referral bonuses, withdrawal limits, "first 100 customers" promotions, inventory reservation, and email verification tokens.

The Uncomfortable Truth 💬

Most developers never think about race conditions until a fraud team pings them at 2 AM saying someone redeemed the same gift card from six locations simultaneously.

The fix is almost always simple — a transaction here, an atomic increment there. But you have to know to look for it. And now you do.

Your concurrent users aren't adversaries. But eventually, one of them will be.


Spotted a race condition in your app? Close the tab, fix it, then come tell me about it on LinkedIn — I love a good war story. 🏁

Want to dig deeper? Check out more security walkthroughs on my GitHub.

P.S. — Go grep your codebase for SELECT * followed by INSERT or UPDATE in the same function. That's where the race is hiding. 🏎️✨

Thanks for reading!

Back to all posts