Topic: mcp server session fixation security
MCP server session fixation security — session hijacking via AI-controlled identifiers
Session fixation is a classic web vulnerability: an attacker supplies a known session ID before authentication, and the server reuses it after the user authenticates — handing control of the authenticated session to the attacker. In MCP servers, this pattern manifests differently and often more subtly: the AI model itself may supply session identifiers or correlation tokens as tool arguments, and a server that trusts these client-supplied values without re-generating them server-side is susceptible to cross-session hijacking. Multi-tenant deployments — where a shared MCP server handles connections from multiple users — are the highest-risk topology.
The MCP session model
A standard MCP session spans one connection from an MCP client (e.g., Claude Code, Cursor) to an MCP server. The session begins when the client connects and ends when it disconnects. For stdio-transport servers, a session is exactly one process lifetime. For HTTP+SSE or WebSocket transport servers, a session maps to a persistent connection.
The session ID itself is typically assigned by the server or transport layer — but some server implementations expose session state via tool arguments, accept correlation tokens from the client, or maintain in-process session maps keyed on client-supplied values. These patterns create session fixation risk.
Vulnerability pattern 1: client-supplied correlation ID accepted as session key
A common pattern in multi-request tool workflows is a correlation ID that links related tool calls:
// Vulnerable
const sessions = new Map(); // in-process session store
server.setRequestHandler('tools/call', async (req) => {
const { sessionId, ...args } = req.params.arguments;
if (!sessions.has(sessionId)) {
sessions.set(sessionId, { userId: null, context: {} });
}
const session = sessions.get(sessionId);
// ... use session
});
If sessionId comes from the AI model (which in turn received it from the MCP client or a prompt-injected instruction), a prompt-injection payload can supply a known sessionId value — specifically one belonging to another user's active session. The server looks up that session and operates on the wrong user's context. In a multi-tenant deployment serving concurrent connections, this is a cross-tenant data leakage vector.
Vulnerability pattern 2: session state not cleared on re-authentication
Servers that support reconnection or re-authentication sometimes reuse session state from before the authentication step:
// Vulnerable
server.setRequestHandler('auth/verify', async (req) => {
const { token } = req.params.arguments;
const user = await verifyToken(token);
// reuses existing session instead of creating new one
currentSession.userId = user.id;
// prior session state (context, cached data) is NOT cleared
});
An attacker who establishes a session, loads it with malicious context (via prompt injection into the session's stored state), then triggers a re-authentication as a victim user causes the victim to inherit the poisoned session state. The victim's subsequent tool calls run against attacker-controlled context.
Vulnerability pattern 3: persistent tool state leaking across requests
Single-user stdio servers sometimes accumulate state in module-level variables that persist across tool calls — not a multi-user concern, but a cross-conversation leak risk if the same server process is reused across Claude Code sessions:
// Vulnerable — module-level accumulation
let conversationContext = []; // persists across all tool calls
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'remember') {
conversationContext.push(req.params.arguments.fact);
}
// returns accumulated context from previous conversations
});
This isn't session fixation in the traditional sense, but it produces the same effect: a new user's conversation starts with residual state from a previous conversation, potentially leaking prior user's data or allowing the previous user to influence the new user's session by pre-loading the context.
Safe pattern: server-generated session IDs only
The fundamental rule: session identifiers must be generated by the server, never accepted from the client.
import { randomUUID } from 'crypto';
// Server assigns the session ID at connection time
server.onConnect((connection) => {
const sessionId = randomUUID(); // server-generated, cryptographically random
connection.sessionId = sessionId;
sessions.set(sessionId, { userId: null, context: {}, createdAt: Date.now() });
});
// Tool handlers receive sessionId from connection context, never from args
server.setRequestHandler('tools/call', async (req, context) => {
const sessionId = context.connection.sessionId; // from transport, not args
const session = sessions.get(sessionId);
// ...
});
Safe pattern: session regeneration on privilege change
Regenerate the session ID whenever authentication state changes — on login, on privilege elevation, and on logout:
server.setRequestHandler('auth/verify', async (req, context) => {
const { token } = req.params.arguments;
const user = await verifyToken(token);
// Invalidate old session
const oldId = context.connection.sessionId;
sessions.delete(oldId);
// Create new session with clean state
const newId = randomUUID();
context.connection.sessionId = newId;
sessions.set(newId, {
userId: user.id,
context: {}, // clean state — no inheritance from pre-auth session
createdAt: Date.now()
});
});
Safe pattern: session TTL and explicit cleanup
In-process session maps that grow unboundedly are both a memory leak and an amplifier for session fixation — longer-lived sessions create more windows for hijacking. Add TTL-based eviction:
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastActivityAt > SESSION_TTL_MS) {
sessions.delete(id);
}
}
}, 60_000);
SkillAudit detection
The Security axis checks for session fixation risk via static analysis: patterns where a tool argument named sessionId, correlationId, requestId, or similar is used as a key into a session store without being validated against a server-side authoritative source. The LLM-probe layer tests whether the server accepts cross-session state lookups by supplying synthetic session IDs that don't belong to the current connection. Findings are classified HIGH when cross-user data access is demonstrable, MEDIUM when the pattern exists but no multi-user deployment is detected.
Check your server's session handling at skillaudit.dev. Related guides: business logic security for state machine patterns and permissions checklist for connection-scope access controls.