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
- lstat/stat followed by readFile/writeFile without O_NOFOLLOW or atomic operation — WARN; TOCTOU symlink race window
- Module-level mutable variables accessed by tool handlers — HIGH if used for auth/session context; WARN if used for caches or counters
- One-time token consumed without atomic mark-and-check — HIGH; token replay window
- Rate limit implementation using read-then-increment without atomic operation — WARN; parallel bypass vector
See also
- MCP server authentication — session and token management patterns
- MCP server multi-tenant security — session isolation between tenants
- MCP server rate limiting — DoS prevention and limit architecture
- MCP server OWASP Top 10 — race conditions in the MCP threat model
- Public audit corpus — race condition findings across scanned servers
Check your concurrent tool handlers for TOCTOU, shared state, and token replay findings.
Run a free audit → How grading works →