Topic: mcp server race condition security

MCP server race condition security — TOCTOU, shared state, and token replay

MCP servers that handle multiple concurrent tool calls — either from parallel LLM tool invocations or from multiple Claude Code sessions against the same server — face race condition attack surfaces that conventional single-threaded servers don't encounter. The four most significant patterns are: TOCTOU file operations (check then act on a file path that can change between check and act), shared session state corruption (concurrent tool calls mutating the same in-memory state), token replay windows (re-using a time-bounded token before it expires using a race), and rate-limit bypass via parallel invocations (saturating per-tool limits by calling simultaneously rather than sequentially).

Attack 1: TOCTOU file operation race

Time-of-check to time-of-use (TOCTOU) is a classic race condition where an attacker changes the filesystem state between the check and the operation that depends on the check. In MCP servers, the most common form occurs when a tool validates that a path is safe (e.g., not a symlink, within an allowed directory), then performs a file operation on that path — and an attacker replaces the file between the check and the operation.

import fs from 'fs'
import path from 'path'

const ALLOWED_BASE = '/app/workspace'

// WRONG: check then act — TOCTOU window between lstat and readFile
async function readFile(filePath: string): Promise {
  const resolved = path.resolve(ALLOWED_BASE, filePath)

  // Check: is this a regular file (not a symlink)?
  const stat = await fs.promises.lstat(resolved)
  if (stat.isSymbolicLink()) throw new Error('Symlinks not allowed')

  // TOCTOU window: attacker replaces resolved path with a symlink here
  // (e.g., using 'ln -sf /etc/passwd /app/workspace/target' in a race loop)

  // Act: reads whatever is at the path NOW — which may be a symlink
  return fs.promises.readFile(resolved, 'utf8')
}

// CORRECT: use O_NOFOLLOW flag to atomically reject symlinks at open time
import { open, constants } from 'fs/promises'

async function readFile(filePath: string): Promise {
  const resolved = path.resolve(ALLOWED_BASE, filePath)

  // Check prefix on the resolved path (follow symlinks to get real path)
  let realPath: string
  try {
    realPath = fs.realpathSync(resolved)
  } catch {
    throw new Error('File not found or access denied')
  }
  if (!realPath.startsWith(ALLOWED_BASE + path.sep)) {
    throw new Error('Access denied: path outside workspace')
  }

  // Open with O_NOFOLLOW: atomically fails if the path is a symlink at open time
  // No TOCTOU window — check and open are one atomic syscall
  const fh = await open(realPath, constants.O_RDONLY | constants.O_NOFOLLOW)
  try {
    return await fh.readFile('utf8')
  } finally {
    await fh.close()
  }
}

Attack 2: Shared session state mutation

MCP servers that store per-session state in module-level variables (Node.js module cache, global Map, singleton class) will corrupt that state when concurrent tool calls from different sessions — or even parallel calls from the same session — mutate it simultaneously. The security consequence is cross-session data leakage: session A's tool call returns data that was partially overwritten by session B's concurrent call.

// WRONG: module-level mutable state shared across all sessions
let currentUser: string | null = null
let pendingOperations: string[] = []

// Tool: authenticate
async function authenticate(sessionId: string, userId: string) {
  currentUser = userId  // RACE: concurrent call from session B overwrites session A's user
  pendingOperations = []
}

// Tool: addOperation
async function addOperation(sessionId: string, op: string) {
  // currentUser may now be session B's user — wrong context for audit logging
  pendingOperations.push(`${currentUser}: ${op}`)
}

// CORRECT: per-session state in a Map keyed by session ID
interface SessionState {
  userId: string
  pendingOperations: string[]
}

// Using WeakMap won't work for string keys — use a regular Map
// with explicit cleanup on session end
const sessions = new Map()

async function authenticate(sessionId: string, userId: string) {
  // Each session gets its own state bucket — no cross-session mutation
  sessions.set(sessionId, { userId, pendingOperations: [] })
}

async function addOperation(sessionId: string, op: string) {
  const state = sessions.get(sessionId)
  if (!state) throw new Error('Session not found — call authenticate first')
  state.pendingOperations.push(`${state.userId}: ${op}`)
}

// Cleanup: remove session state when the MCP session ends
// Register with server.on('session_end', (sessionId) => sessions.delete(sessionId))

Attack 3: Token replay window race

Short-lived tokens (OAuth access tokens, CSRF tokens, one-time API keys) are often validated by checking an expiry timestamp. An attacker who intercepts or observes a token can replay it concurrently — sending multiple requests simultaneously before the server processes any of them — so that all requests pass the timestamp check before any one of them is marked as used. This is the token replay race.

// WRONG: check expiry then mark as used — race between concurrent requests
const usedTokens = new Set()

async function consumeToken(token: string): Promise {
  const { exp, jti } = parseJwt(token)

  // Check: not expired, not used
  if (Date.now() / 1000 > exp) return false
  if (usedTokens.has(jti)) return false

  // RACE WINDOW: two concurrent requests both pass the check above
  // before either adds jti to usedTokens

  usedTokens.add(jti)  // Both requests add it — second add is a no-op
  return true           // Both requests return true — token replayed successfully
}

// CORRECT: atomic check-and-set using a mutex or atomic data structure
// In a single Node.js process, a Map + Promise mutex prevents the race

const tokenLocks = new Map>()
const usedTokens = new Map()  // jti -> expiry timestamp

async function consumeToken(token: string): Promise {
  const { exp, jti } = parseJwt(token)

  // Serialize concurrent calls for the same token using a promise chain
  const prev = tokenLocks.get(jti) ?? Promise.resolve(false)

  const current = prev.then(async (alreadyConsumed) => {
    if (alreadyConsumed) return false
    if (Date.now() / 1000 > exp) return false
    if (usedTokens.has(jti)) return false  // Double-check inside the lock

    // Atomically mark as used
    usedTokens.set(jti, exp)

    // Schedule cleanup after expiry to prevent unbounded Map growth
    setTimeout(() => usedTokens.delete(jti), (exp - Date.now() / 1000) * 1000 + 5000)

    return true
  })

  tokenLocks.set(jti, current.then(() => true).catch(() => false))
  return current
}

// For distributed deployments: use Redis SET jti EX ttl NX
// SET returns OK only if key didn't exist — atomically prevents replay
// redis.set(jti, '1', { NX: true, EX: ttlSeconds })

Attack 4: Rate-limit bypass via parallel invocations

Rate limits implemented as read-check-increment patterns are vulnerable to parallel bypass: an attacker sends N requests simultaneously, all of which read the current counter as 0 (or below the limit), all of which pass the check, and all of which then increment the counter — resulting in N successful requests even though the limit is 1. This is the parallel saturation attack.

// WRONG: non-atomic read-check-increment
const callCounts = new Map()
const LIMIT = 10  // 10 calls per minute

async function rateLimitedTool(sessionId: string, args: unknown) {
  const count = callCounts.get(sessionId) ?? 0

  // Parallel requests all read count = 0, all pass this check
  if (count >= LIMIT) throw new Error('Rate limit exceeded')

  // All increment from 0 — final count is 1, not N
  callCounts.set(sessionId, count + 1)

  return performOperation(args)
}

// CORRECT: atomic increment-then-check pattern
// In-process: use a per-session atomic counter with a lock

class AtomicCounter {
  private count = 0
  private resetAt: number

  constructor(private readonly limit: number, windowMs: number) {
    this.resetAt = Date.now() + windowMs
  }

  tryIncrement(): boolean {
    if (Date.now() > this.resetAt) {
      this.count = 0
      this.resetAt = Date.now() + 60_000
    }
    // Increment first, then check — any concurrent caller that gets here
    // with count already at limit will fail, regardless of timing
    this.count += 1
    return this.count <= this.limit
  }
}

const sessionCounters = new Map()

async function rateLimitedTool(sessionId: string, args: unknown) {
  if (!sessionCounters.has(sessionId)) {
    sessionCounters.set(sessionId, new AtomicCounter(10, 60_000))
  }
  const counter = sessionCounters.get(sessionId)!

  // Atomic: increment happens before the check
  if (!counter.tryIncrement()) {
    throw new Error('Rate limit exceeded — 10 calls per minute')
  }

  return performOperation(args)
}

// For distributed MCP servers: use Redis INCR with EXPIRE
// const count = await redis.incr(key)
// if (count === 1) await redis.expire(key, 60)  // Set TTL on first increment
// if (count > LIMIT) throw new Error('Rate limit exceeded')

What SkillAudit checks

See also

Check your concurrent tool handlers for TOCTOU, shared state, and token replay findings.

Run a free audit → How grading works →