Why race conditions matter in security code

A race condition occurs when the correctness of an operation depends on timing. In security-sensitive code, that timing dependency can become an exploit.

Common examples include:

  • Two requests redeeming the same password reset token at nearly the same time
  • Concurrent increments of a failed-login counter that allow brute-force attempts to slip through
  • A check-then-act sequence that validates a permission, then uses it after the underlying state changed
  • Multiple workers processing the same job or webhook event twice

The key lesson is simple: never separate a security decision from the state transition that makes it true unless the transition is protected by a single atomic operation or a lock.


The core patterns that cause bugs

Most race conditions in Rust come from one of these patterns:

  1. Check-then-act
  • Read state
  • Make a decision
  • Write updated state later
  • Another thread changes the state in between
  1. Lost update
  • Two threads read the same old value
  • Both compute a new value
  • The later write overwrites the earlier one
  1. Double-spend / double-use
  • A token, coupon, or session is intended to be used once
  • Two concurrent requests both succeed before either marks it consumed
  1. TOCTOU
  • Time-of-check to time-of-use bugs
  • Especially dangerous when state is stored outside the process, such as in a database or cache

Rust helps you express safe concurrency, but it cannot infer your business invariants. You must design them explicitly.


Choose the right synchronization primitive

Rust gives you several ways to coordinate access to shared state. The right choice depends on whether you need mutual exclusion, atomic counters, or message passing.

PrimitiveBest forSecurity note
Mutex<T>Small critical sections, compound updatesProtects check-and-update as one operation
RwLock<T>Mostly-read workloadsWriters can still race with stale assumptions if logic is split
AtomicUsize, AtomicBool, etc.Simple counters and flagsOnly safe for single-variable invariants
ChannelsOwnership transfer and serialized processingGood for avoiding shared mutable state entirely
Database transactionsCross-process state and durable invariantsEssential for multi-instance services

For security-sensitive state, prefer the narrowest mechanism that preserves the invariant. If the invariant spans multiple fields, a Mutex or transaction is usually safer than atomics.


Example: fixing a one-time token redemption bug

Suppose you implement password reset tokens in memory. A naive version might look like this:

use std::collections::HashSet;
use std::sync::{Arc, Mutex};

struct TokenStore {
    used: HashSet<String>,
}

impl TokenStore {
    fn new() -> Self {
        Self {
            used: HashSet::new(),
        }
    }

    fn redeem_naive(&mut self, token: &str) -> bool {
        if self.used.contains(token) {
            return false;
        }

        // Simulate work before marking the token as used.
        self.used.insert(token.to_string());
        true
    }
}

This looks fine in single-threaded code, but in a concurrent service the contains and insert steps must be atomic relative to each other. If two threads both observe that the token is unused, both can redeem it.

A safer version keeps the entire decision inside one lock:

use std::collections::HashSet;
use std::sync::{Arc, Mutex};

struct TokenStore {
    used: Mutex<HashSet<String>>,
}

impl TokenStore {
    fn new() -> Self {
        Self {
            used: Mutex::new(HashSet::new()),
        }
    }

    fn redeem(&self, token: &str) -> bool {
        let mut used = self.used.lock().expect("mutex poisoned");

        if used.contains(token) {
            return false;
        }

        used.insert(token.to_string());
        true
    }
}

The important property is not the Mutex itself; it is that the check and the state change happen under the same lock.


Avoid splitting authorization from mutation

A common security mistake is to verify a condition, release the lock, and then perform the sensitive action later.

For example:

  1. Check that a user is allowed to transfer funds
  2. Drop the lock
  3. Perform the transfer

If another thread changes the account state after step 1, the transfer may proceed on stale assumptions.

Instead, keep the invariant and the mutation together:

use std::sync::Mutex;

struct Account {
    balance_cents: u64,
    frozen: bool,
}

struct Bank {
    account: Mutex<Account>,
}

impl Bank {
    fn withdraw(&self, amount: u64) -> Result<(), &'static str> {
        let mut account = self.account.lock().expect("mutex poisoned");

        if account.frozen {
            return Err("account frozen");
        }

        if account.balance_cents < amount {
            return Err("insufficient funds");
        }

        account.balance_cents -= amount;
        Ok(())
    }
}

This pattern is especially important for:

  • Rate-limit counters
  • Session revocation
  • Permission checks tied to mutable state
  • Replay protection

If the action depends on the state, the state transition should be part of the same critical section or transaction.


Use atomics only for simple invariants

Atomics are fast and useful, but they are easy to misuse. They work best when the invariant is a single number or flag.

Good fits:

  • Request counters
  • Feature flags
  • “Already initialized” markers
  • Retry budgets

Poor fits:

  • Token stores
  • Multi-field authorization state
  • Anything requiring “check then update” across more than one variable

A safe counter example:

use std::sync::atomic::{AtomicUsize, Ordering};

struct RateLimiter {
    remaining: AtomicUsize,
}

impl RateLimiter {
    fn try_consume(&self) -> bool {
        let mut current = self.remaining.load(Ordering::Relaxed);

        loop {
            if current == 0 {
                return false;
            }

            match self.remaining.compare_exchange_weak(
                current,
                current - 1,
                Ordering::AcqRel,
                Ordering::Relaxed,
            ) {
                Ok(_) => return true,
                Err(observed) => current = observed,
            }
        }
    }
}

This uses a compare-and-swap loop to make the decrement atomic. However, if your security rule depends on multiple fields, atomics alone are usually the wrong tool. A Mutex is often simpler and less error-prone.


Prefer serialized ownership when possible

The safest way to avoid races is to avoid shared mutable state entirely.

Instead of letting many workers mutate a structure directly, route requests through a single owner task or actor. That task receives messages and applies state changes sequentially.

This is useful for:

  • In-memory session registries
  • Per-user rate limiting
  • Deduplication caches
  • Security event aggregation

A simplified pattern:

use tokio::sync::mpsc;

enum Command {
    RedeemToken(String, tokio::sync::oneshot::Sender<bool>),
}

async fn token_worker(mut rx: mpsc::Receiver<Command>) {
    let mut used = std::collections::HashSet::new();

    while let Some(cmd) = rx.recv().await {
        match cmd {
            Command::RedeemToken(token, reply) => {
                let accepted = used.insert(token);
                let _ = reply.send(accepted);
            }
        }
    }
}

Because only one task owns the HashSet, there is no race between checking and inserting. This approach is especially attractive when the state machine is complex and the throughput requirements are moderate.


Be careful with async locks

In async Rust, tokio::sync::Mutex and similar primitives are often necessary, but they introduce a different risk: holding a lock across .await.

If you hold a lock while awaiting network I/O or disk I/O, you can block unrelated tasks and create contention or deadlocks.

Safer pattern

  • Lock
  • Copy or extract the data you need
  • Unlock
  • Perform async work
  • Re-lock only if needed for a small update
use tokio::sync::Mutex;

struct Session {
    revoked: bool,
}

struct Store {
    session: Mutex<Session>,
}

impl Store {
    async fn is_active(&self) -> bool {
        let session = self.session.lock().await;
        !session.revoked
    }
}

If you need a more complex sequence, keep the locked section short and avoid awaiting inside it unless the lock is specifically designed for that use case and the design has been reviewed carefully.


Use database transactions for durable security invariants

In-memory locks only protect a single process. If your application runs multiple instances, or if the state must survive restarts, use the database to enforce the invariant.

Examples:

  • Redeeming a one-time token
  • Incrementing failed login attempts
  • Marking a webhook event as processed
  • Revoking a session across a cluster

A typical pattern is:

  1. Start a transaction
  2. Read the row with the appropriate lock or isolation level
  3. Validate the condition
  4. Update the row
  5. Commit

The exact SQL depends on your database, but the principle is the same: let the database serialize the critical update.

For example, a token redemption flow should be designed so that only one transaction can mark a token as consumed. If two requests race, one should succeed and the other should fail cleanly.


Design rules for race-resistant Rust code

Use these rules when reviewing security-sensitive concurrency:

  • Keep the check and the state change together
  • Prefer one owner for mutable state
  • Use Mutex for compound invariants
  • Use atomics only for single-variable state
  • Avoid holding locks across .await
  • Use database transactions for cross-process correctness
  • Treat caches as hints, not sources of truth
  • Make repeated requests idempotent where possible

A useful mental model is: if two requests arrive at the same time, what exact line of code guarantees only one can win? If you cannot point to that line, the design is probably vulnerable.


Testing for concurrency bugs

Race conditions are often hard to reproduce manually. Add tests that stress interleavings and validate invariants.

Practical techniques:

  • Run tests under high parallelism
  • Use repeated loops to increase the chance of collisions
  • Test that one-time operations succeed exactly once
  • Verify counters never go negative or exceed expected bounds
  • Simulate duplicate requests and retries

For example, a token redemption test should assert that exactly one of many concurrent attempts succeeds.

Also consider code review questions such as:

  • Is any state read outside the lock and written inside it?
  • Can two threads observe the same precondition?
  • Is the operation safe if retried?
  • Does the code still work with two application instances?

Summary

Race conditions are not memory-safety bugs, but they are still security bugs. In Rust, the safest designs are the ones that make correctness structural:

  • Serialize ownership when you can
  • Use locks to protect compound invariants
  • Use atomics only for simple counters and flags
  • Use transactions for durable shared state
  • Keep security decisions and state transitions atomic

If a token can only be redeemed once, make “redeemed” a single atomic fact. If a user can only perform an action under a condition, ensure the condition is checked and consumed together. That is the difference between code that merely compiles and code that remains correct under pressure.

Learn more with useful resources