Claude skill session and state security
Stateful MCP skills — skills that track multi-step workflows, maintain user context between tool calls, or manage session-scoped resources — introduce a category of vulnerabilities specific to how state is stored and how session identity is established. Because Node.js is a single-process, event-loop-driven runtime that handles concurrent requests on the same module instance, shared mutable state at module scope is overwritten by concurrent sessions, creating race conditions that expose one user’s data to another. When state machine transitions are not validated, attackers skip prerequisite authorization steps by calling later steps directly. Weak session ID generation allows prediction and hijacking. Session fixation binds victims to attacker-controlled sessions. SA-SKL-SESS covers all four patterns.
MCP skills are unusual among server-side applications in that the same process handles tool calls from many different users concurrently, with the user identity often derived from the conversation context rather than from a traditional authenticated HTTP session. This means that state management bugs which are uncommon in single-user desktop applications or low-concurrency prototypes become critical vulnerabilities when the skill is deployed as a shared service. Session state bugs in MCP skills commonly manifest as cross-user data leakage — user B sees user A’s data because both tool calls share the same module-level variable — which is both a security failure and a trust failure that is difficult to detect without active adversarial testing.
SA-SKL-SESS-001: Shared mutable module-level state across concurrent tool calls — cross-user data leakage
Module-level variables in Node.js are shared across all requests processed by the same process instance. A skill that stores user context in a module-level let or var variable — let currentUser = null, let activeSession = {}, let pendingOperation = null — will overwrite that variable each time a new tool call begins. Under concurrent load (two users calling the same tool simultaneously), the second call’s user context can be assigned before the first call completes its operation, causing the first call to execute with the second user’s identity, permissions, or data:
// VULNERABLE: module-level mutable state shared across concurrent requests
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const server = new McpServer({ name: 'data-skill', version: '1.0.0' });
// VULNERABLE: module-level variable shared across all concurrent requests
let currentUserId: string | null = null;
let currentUserPermissions: string[] = [];
let pendingQuery: string | null = null;
server.tool('prepare_query', { userId: z.string(), query: z.string() }, async (args) => {
currentUserId = args.userId; // RACE: overwritten if second call starts
currentUserPermissions = await loadPermissions(args.userId); // async gap
pendingQuery = args.query; // RACE: second call overwrites this
return { ready: true };
});
server.tool('execute_query', {}, async () => {
// RACE CONDITION:
// User A: prepare_query({ userId: 'alice', query: 'SELECT salary FROM employees' })
// User B: prepare_query({ userId: 'bob', query: 'SELECT public_data FROM items' })
// Timeline:
// t=0: User A sets currentUserId = 'alice', starts loadPermissions('alice') [awaits]
// t=1: User B sets currentUserId = 'bob', starts loadPermissions('bob') [awaits]
// t=2: User B's execute_query runs with currentUserId='bob', pendingQuery='SELECT...'
// BUT loadPermissions returned alice's permissions (promise resolution order)
// User B executes alice's salary query with alice's permissions = data leak
if (!hasPermission(currentUserPermissions, pendingQuery)) {
return { error: 'Permission denied' };
}
return await runQuery(pendingQuery); // may run wrong user's query
});
// SECURE: use per-request context, not module-level state
// Option 1: pass all state as tool args (stateless — best for simple flows)
server.tool('execute_query', {
userId: z.string(),
query: z.string(),
permissions: z.array(z.string()),
}, async (args) => {
// All state derived from the current request's args — no shared state
const perms = await loadPermissions(args.userId); // load fresh per call
if (!hasPermission(perms, args.query)) {
return { error: 'Permission denied' };
}
return await runQuery(args.query);
});
// Option 2: WeakMap keyed on request context object (for SDK-provided contexts)
const requestState = new WeakMap<RequestContext, UserState>();
server.tool('prepare_query', { userId: z.string(), query: z.string() }, async (args, ctx) => {
requestState.set(ctx, {
userId: args.userId,
permissions: await loadPermissions(args.userId),
query: args.query,
});
return { ready: true };
});
Module-level state is process-global in Node.js: Unlike Java servlet instances or Python WSGI processes that may create a new object per request, Node.js modules are singletons — the module code runs once when first imported, and all subsequent imports return the cached module object. Any let or var at module scope is shared across all concurrent and sequential requests. This is correct behavior for connection pools and application configuration, but it is a security vulnerability for per-request user context. The mental model “each request has its own scope” is true for local variables inside async handler functions but false for module-level variables.
let/var at module scope holds user context, permissions, or operation state; concurrent tool calls overwrite the shared variable; user A’s data or permissions used by user B’s operation. Fix: derive all per-request state from the current call’s args; use WeakMap keyed on request context for multi-step flows. Detection: let/var at module top level modified inside tool handler → CRITICAL.SA-SKL-SESS-002: Tool call state machine without transition validation — authorization step bypass
Multi-step MCP skill workflows — authenticate then authorize then execute, verify identity then confirm payment then process, check permissions then stage then commit — implement a state machine where each step must follow the previous one. When this state machine is tracked in memory without cryptographic binding to the user identity and without server-side validation of the transition sequence, an attacker can call step 3 directly without completing steps 1 and 2. This is particularly dangerous for authorization flows where step 1 is the authentication check: bypassing it allows the attacker to reach the privileged execution step without credentials:
// VULNERABLE: in-memory state machine without transition validation
const workflowState = new Map<string, { step: string; userId: string }>();
server.tool('step1_authenticate', { sessionToken: z.string() }, async (args) => {
const userId = await verifyToken(args.sessionToken);
if (!userId) return { error: 'Invalid token' };
workflowState.set(args.sessionToken, { step: 'authenticated', userId });
return { step: 'authenticated' };
});
server.tool('step2_authorize', { sessionToken: z.string(), resource: z.string() }, async (args) => {
const state = workflowState.get(args.sessionToken);
if (state?.step !== 'authenticated') return { error: 'Must authenticate first' };
const permitted = await checkPermission(state.userId, args.resource);
if (!permitted) return { error: 'Unauthorized' };
workflowState.set(args.sessionToken, { ...state, step: 'authorized', resource: args.resource });
return { step: 'authorized' };
});
server.tool('step3_execute', { sessionToken: z.string() }, async (args) => {
const state = workflowState.get(args.sessionToken);
// VULNERABLE: state lookup by attacker-controlled sessionToken
// Attacker who doesn't have a valid token:
// 1. Calls step1_authenticate with an invalid token — receives error
// 2. Calls step2_authorize with their own token — receives error (step not authenticated)
// BUT: what if workflowState has a stale entry from a previous session?
// OR: what if attacker guesses another user's sessionToken (predictable, or guessed)?
// Map lookup uses exact string match — attacker needs the token value
// DEEPER FLAW: state machine state is LOST if the process restarts
// And: attacker who gets any valid token (even their own) can call step3 directly
// if they can forge or guess the state map entry for another user's token
if (state?.step !== 'authorized') return { error: 'Must authorize first' };
return await executePrivilegedAction(state);
});
// SECURE: cryptographically signed state tokens that encode step + userId
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
const STATE_SIGNING_KEY = requireEnv('STATE_TOKEN_SECRET');
function issueStateToken(userId: string, step: string, data: Record<string, unknown>): string {
const payload = JSON.stringify({ userId, step, data, iat: Date.now(), jti: randomBytes(8).toString('hex') });
const payloadB64 = Buffer.from(payload).toString('base64url');
const mac = createHmac('sha256', STATE_SIGNING_KEY).update(payloadB64).digest('base64url');
return `${payloadB64}.${mac}`;
}
function verifyStateToken(token: string, expectedStep: string, userId: string): Record<string, unknown> {
const [payloadB64, mac] = token.split('.');
if (!payloadB64 || !mac) throw new Error('Invalid token format');
const expectedMac = createHmac('sha256', STATE_SIGNING_KEY).update(payloadB64).digest('base64url');
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expectedMac))) {
throw new Error('Token signature invalid');
}
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
if (payload.step !== expectedStep) throw new Error(`Expected step ${expectedStep}, got ${payload.step}`);
if (payload.userId !== userId) throw new Error('Token userId mismatch');
if (Date.now() - payload.iat > 5 * 60 * 1000) throw new Error('State token expired');
return payload.data;
}
server.tool('step3_execute', { stateToken: z.string(), userId: z.string() }, async (args) => {
try {
const data = verifyStateToken(args.stateToken, 'authorized', args.userId);
return await executePrivilegedAction(data);
} catch (e) {
return { error: 'Invalid or expired workflow state' };
}
});
In-memory state machines don’t survive process restarts: A Map or WeakMap in Node.js module scope is cleared when the process exits. If your skill is deployed with auto-restart (PM2, Kubernetes, Docker restart policy), the state machine resets every time the process restarts. Users in the middle of a multi-step workflow lose their state silently. Cryptographically signed state tokens (or database-backed sessions) survive restarts because the state is carried by the client token, not stored server-side. For short-lived workflows (<5 minutes), signed tokens are preferable to database sessions because they add no infrastructure dependency.
Map tracks workflow step; state lost on restart; token-guessing or stale entries allow step bypass; authorization steps skippable. Fix: cryptographically signed state tokens (HMAC-SHA256 over payload including userId, step, iat); verify signature + step + userId + expiry on each transition. Detection: Map.get(args.token) + step comparison without cryptographic validation → HIGH.SA-SKL-SESS-003: Session ID generated with Math.random() — PRNG state recoverable, session hijacking
When an MCP skill generates session identifiers using Math.random(), the identifiers are pseudorandom but not cryptographically unpredictable. V8’s Math.random() implementation uses the xorshift128+ algorithm with a 128-bit state. The full PRNG state can be recovered from as few as three consecutive Math.random() outputs using a constraint-solving approach, after which all future and past outputs are predictable. An attacker who can observe a few session IDs (from public endpoints, shared links, or log leakage) can recover the PRNG state and predict all current and future session IDs, enabling targeted session hijacking:
// VULNERABLE: session IDs generated with Math.random()
// Pattern 1: direct Math.random() as session ID
function generateSessionId(): string {
return Math.random().toString(36).slice(2); // only 52 bits of entropy, predictable state
}
// Pattern 2: UUID-looking but still Math.random()-based
function generateUUID(): string {
// Some UUID generators use Math.random() — check the implementation!
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0; // VULNERABLE: Math.random()
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
// Pattern 3: timestamp + Math.random() (timestamp reduces entropy further)
const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
// Timestamp is predictable ± milliseconds; Math.random() state is recoverable
// Combined: easier to brute-force, not harder
// ATTACK: V8 xorshift128+ PRNG state recovery
// 1. Observe 3 consecutive Math.random() outputs (from public session IDs, log leakage,
// shared session links, debug output, timing-based inference)
// 2. Use z3 constraint solver or XorShift128+ state recovery tool to recover PRNG state
// (~100ms on a modern laptop)
// 3. Enumerate all past and future session IDs: target high-privilege users whose sessions
// were created around the same time as the observed samples
// 4. Use predicted session ID in Authorization header or Cookie to hijack session
// SECURE: cryptographically random session IDs using Node.js crypto module
import { randomBytes, randomUUID } from 'crypto';
// Option 1: hex string with 256 bits of entropy (best for opaque session tokens)
function generateSecureSessionId(): string {
return randomBytes(32).toString('hex'); // 64-char hex, 256 bits, CSPRNG
}
// Option 2: UUID v4 from crypto (not from Math.random-based library)
function generateSecureUUID(): string {
return randomUUID(); // Node.js built-in: uses OS CSPRNG, RFC 4122 UUID v4
}
// STARTUP VALIDATION: detect weak PRNG usage in session ID generation
// Add to your test suite:
function assertSessionIdsAreUnique(): void {
const ids = new Set(Array.from({ length: 1000 }, () => generateSecureSessionId()));
if (ids.size !== 1000) throw new Error('Session ID generator produced duplicates');
// Also check entropy: each ID should be at least 32 hex chars (128 bits)
for (const id of ids) {
if (id.length < 32) throw new Error(`Session ID too short: ${id.length} chars`);
}
}
Not all UUID libraries use a CSPRNG: Some UUID v4 libraries in npm (particularly older ones) use Math.random() internally for UUID generation. Node.js’s built-in crypto.randomUUID() (available since Node 15.6) uses the operating system CSPRNG and is safe. The uuid npm package also uses a CSPRNG. But inline UUID template implementations (the 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace() pattern that appears widely in tutorials and Stack Overflow answers) almost universally use Math.random(). Use import { randomUUID } from 'crypto' rather than any third-party UUID library or inline implementation.
Math.random()-based session IDs: V8 xorshift128+ PRNG state recoverable from 3 observed outputs; all past and future session IDs predictable; targeted session hijacking. Fix: crypto.randomBytes(32).toString('hex') or crypto.randomUUID(); never use Math.random() for security-relevant identifiers. Detection: Math.random() in session ID generator function → HIGH.SA-SKL-SESS-004: Session fixation — attacker supplies session ID and victim binds to it
Session fixation is an attack where the attacker establishes a session on the target skill with a known session ID, then tricks the victim into using that same session ID during authentication. After the victim authenticates, their identity is bound to the session that the attacker already knows. The attacker uses the session ID they supplied to access the now-authenticated session. In MCP skills, this manifests as accepting a session identifier from a tool argument or HTTP header and treating it as the user’s session, rather than always generating a fresh cryptographically random session ID server-side:
// VULNERABLE: accepting session ID from client-supplied tool argument
server.tool('initialize_session', {
sessionId: z.string().optional(), // VULNERABLE: client can supply their own session ID
userId: z.string(),
}, async (args) => {
// If args.sessionId is provided, use it — allows attacker to fix session ID
const sessionId = args.sessionId ?? generateSessionId();
sessions.set(sessionId, { userId: args.userId, authenticated: false });
return { sessionId };
});
// ATTACK:
// 1. Attacker calls initialize_session({ sessionId: 'attacker-chosen-id', userId: 'victim' })
// Session 'attacker-chosen-id' is created, not yet authenticated
// 2. Attacker sends victim a link that includes sessionId 'attacker-chosen-id'
// (e.g. in a phishing message: "Click here to continue" with sessionId embedded)
// 3. Victim calls authenticate_session({ sessionId: 'attacker-chosen-id', password: '...' })
// Skill authenticates the victim and sets sessions.get('attacker-chosen-id').authenticated = true
// 4. Attacker, who knows 'attacker-chosen-id', calls any authenticated tool with that sessionId
// Skill looks up the session, finds it authenticated as the victim — attacker has access
// Also vulnerable: accepting session ID from HTTP header or query param without regenerating
app.post('/login', (req, res) => {
const sessionId = req.headers['x-session-id'] ?? generateSessionId(); // VULNERABLE if header present
const userId = authenticate(req.body.username, req.body.password);
sessions.set(sessionId, { userId, authenticated: true });
res.json({ sessionId });
});
// SECURE: always generate server-side session ID; never accept from client
server.tool('initialize_session', {
userId: z.string(),
// NO sessionId parameter — session ID is always server-generated
}, async (args) => {
// Generate fresh cryptographic session ID on the server — client has no input
const sessionId = randomBytes(32).toString('hex'); // 256 bits, CSPRNG
sessions.set(sessionId, { userId: args.userId, authenticated: false });
return { sessionId }; // return the server-generated ID to the client
});
// ON LOGIN: regenerate session ID even if one already exists
// (prevents fixation via pre-authentication session establishment)
server.tool('authenticate', {
existingSessionId: z.string(),
password: z.string(),
}, async (args) => {
const preAuthState = sessions.get(args.existingSessionId);
if (!preAuthState) return { error: 'Session not found' };
const userId = await verifyPassword(preAuthState.userId, args.password);
if (!userId) return { error: 'Invalid credentials' };
// CRITICAL: delete the pre-authentication session and issue a NEW session ID
sessions.delete(args.existingSessionId);
const newSessionId = randomBytes(32).toString('hex'); // fresh ID post-login
sessions.set(newSessionId, { userId, authenticated: true });
return { sessionId: newSessionId }; // client must use the new ID going forward
});
Regenerate session ID on privilege change, not just on login: Session fixation attacks are possible whenever a session transitions from a less-privileged state to a more-privileged state without a session ID change. This includes: unauthenticated → authenticated (login), authenticated → admin (privilege escalation), read-only → write-enabled (scope grant), and anonymous → OAuth-linked (OAuth callback). Generate a new randomBytes(32) session ID and invalidate the old one at every such transition. The session ID change signals to the browser (or to Claude) that the new session token must be used for subsequent requests.
sessionId as optional arg; attacker pre-establishes known session ID; victim authenticates into attacker-known session; attacker hijacks authenticated session. Fix: never accept sessionId from client; always generate with randomBytes(32) on server; regenerate on every authentication event. Detection: sessionId as optional input schema parameter with ?? generateSessionId() fallback → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| SESS-001: Shared module-level mutable state | CRITICAL | Derive per-request state from args; use WeakMap keyed on request context for multi-step flows; no module-level let/var for user context |
| SESS-002: State machine without transition validation | HIGH | HMAC-SHA256 signed state tokens encoding userId + step + iat; verify signature + step + userId + expiry on each transition |
SESS-003: Math.random() session IDs | HIGH | crypto.randomBytes(32).toString('hex') or crypto.randomUUID(); never use Math.random() for session identifiers |
| SESS-004: Session fixation via client-supplied session ID | HIGH | Never accept sessionId as tool input; always generate server-side; regenerate on every authentication / privilege elevation |
SkillAudit detects all four SA-SKL-SESS-001 through SESS-004 patterns through static analysis of skill session management code: module-level let/var assignments inside tool handlers, in-memory Map state machines without cryptographic token validation, Math.random() usage in session ID generation functions, and optional sessionId input parameters with server-side session lookup. For related security patterns see SA-SKL-AUT authentication bypass, SA-SKL-CRY cryptographic implementation, and SA-SKL-ACL access control. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.
Scan your Claude skill for session and state security vulnerabilities
SkillAudit detects shared module state races, state machine bypasses, weak session ID generation, and session fixation across the full SA-SKL-SESS family in 60 seconds.
Get your free audit