Blog · 2026-06-15 · Security · Sessions · LLM Agents
MCP Server Session Fixation and Hijacking: Stateful Session Attacks in Long-Lived LLM Agent Contexts
Web session attacks are well understood: session fixation pre-sets a session ID before the user authenticates; session hijacking steals the token afterward. MCP servers that maintain stateful sessions across multiple LLM agent calls inherit both attack classes — but with a uniquely dangerous property that traditional web sessions don't have. An agent session can span hours of autonomous work, executing file writes, API calls, database queries, and tool chaining across dozens of steps. Compromise that session and you don't get a browser tab — you get hours of agent actions running under legitimate credentials with no human watching for anomalies.
The rise of stateful MCP servers is a direct consequence of how LLM agents are deployed in production. Stateless tool calls are simple but expensive: every call to a database-query tool re-authenticates against the database; every call to a GitHub tool re-exchanges OAuth tokens; every call to an internal API re-validates credentials. Stateful sessions solve this by authenticating once and then issuing a session token that subsequent tool calls use to retrieve the established context. This is the right performance tradeoff — but it creates a session management surface that most MCP server implementations haven't been designed to defend.
This post covers three concrete attack scenarios with Node.js code, explains why LLM agent sessions amplify the risk beyond ordinary web sessions, and documents the session ID generation and management practices that eliminate the attack surface.
What makes LLM agent sessions uniquely at risk
Before diving into the specific attacks, it's worth establishing the threat model difference between a web session and an LLM agent session.
Duration. A typical authenticated web session lasts minutes to hours, with most user interaction happening in the first few minutes after login. An LLM agent session launched to complete a complex task — migrate a codebase, analyze a dataset, execute a multi-step deployment — can run for hours without any human interaction. The session token that grants agent capabilities is valid for the entire autonomous window.
Action density. A web session carries a human's click-rate of actions — a few per minute at most. An LLM agent makes tool calls at machine speed. A compromised 4-hour agent session might execute thousands of tool calls before anyone notices something is wrong. Each tool call carries the full authority of the session.
Absence of anomaly detection. When a human web session behaves anomalously, there are signals: unusual IP geolocation, browser fingerprint changes, action patterns outside the user's history. An attacker who has hijacked an agent session generates actions that look like normal agent behavior — rapid, automated, tool-calling activity is exactly what a legitimate agent session looks like. Behavioral anomaly detection that works for humans fails for agents.
Broad tool authority. Agent sessions often hold credentials for many downstream systems simultaneously — GitHub OAuth tokens, database connection strings, internal API keys — all accessible within the same session context. Hijacking one session token can hand the attacker access to every system the agent was authorized to use.
The asymmetry: In a web session hijack, an attacker gets the permissions of one logged-in user for the duration of their browser session. In an agent session hijack, an attacker gets the permissions of every downstream system the agent was authorized against — database, APIs, file system — for hours of autonomous execution with no human in the loop.
Attack Scenario 1: Session fixation via pre-auth session ID
Attacker pre-sets a known session ID that survives authentication
Session fixation works when the server issues a session ID before authentication and reuses that same ID after the user or agent authenticates. The attacker obtains a pre-auth session ID, tricks the agent into using it for authentication, and then uses the same known ID to access the now-authenticated session.
- Attacker sends a request to the MCP server's session-init endpoint and receives a session ID:
sessionId = "abc123def456" - Attacker embeds this session ID in a crafted MCP connection string or tricks the agent into connecting with a pre-specified session parameter
- Agent authenticates using the attacker's pre-set session ID — the server upgrades the session to authenticated status without regenerating the ID
- Attacker, who already knows
sessionId = "abc123def456", sends tool calls using that ID and receives authenticated responses
Here is the vulnerable pattern — a common implementation where the session ID is issued before authentication and reused after:
// VULNERABLE: session ID issued before auth, not regenerated on authentication
import express from "express";
import session from "express-session";
import crypto from "crypto";
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: true, // Session created BEFORE authentication — fixation risk
genid: () => crypto.randomBytes(16).toString("hex"),
cookie: { httpOnly: true, secure: true, maxAge: 4 * 60 * 60 * 1000 }
}));
// GET /session/init — called before agent provides credentials
app.get("/session/init", (req, res) => {
// Session is already created by express-session middleware
// The session ID at this point is the PRE-AUTH ID
res.json({ sessionId: req.session.id, status: "pending_auth" });
});
// POST /session/authenticate — agent provides credentials to authenticate
app.post("/session/authenticate", async (req, res) => {
const { apiKey } = req.body;
if (!isValidApiKey(apiKey)) {
return res.status(401).json({ error: "Invalid API key" });
}
// VULNERABLE: we mark the session authenticated WITHOUT regenerating the ID
// The session ID that the attacker knows is now an authenticated session
req.session.authenticated = true;
req.session.userId = lookupUserByKey(apiKey);
req.session.save((err) => {
res.json({ status: "authenticated", sessionId: req.session.id });
// req.session.id is STILL "abc123def456" — the pre-auth ID the attacker obtained
});
});
The fix is to call req.session.regenerate() immediately after successful authentication. This creates a new session ID while preserving any session data that needs to carry over. The old session ID is invalidated:
// SECURE: regenerate session ID on authentication
app.post("/session/authenticate", async (req, res) => {
const { apiKey } = req.body;
if (!isValidApiKey(apiKey)) {
return res.status(401).json({ error: "Invalid API key" });
}
const userId = lookupUserByKey(apiKey);
// Regenerate session ID — the old pre-auth ID is invalidated
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: "Session regeneration failed" });
}
// New session ID is now in req.session.id
// Old ID ("abc123def456") is gone from the session store
req.session.authenticated = true;
req.session.userId = userId;
req.session.authenticatedAt = Date.now();
req.session.save((saveErr) => {
if (saveErr) {
return res.status(500).json({ error: "Session save failed" });
}
// Only send the NEW session ID to the client
res.json({ status: "authenticated", sessionId: req.session.id });
});
});
});
// Also: do not initialize sessions for unauthenticated requests
// Set saveUninitialized: false to avoid creating session records before auth
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false, // No session created until explicitly saved
genid: () => crypto.randomBytes(32).toString("hex"),
cookie: { httpOnly: true, secure: true, maxAge: 4 * 60 * 60 * 1000, sameSite: "strict" }
}));
The critical change is saveUninitialized: false combined with explicit session.regenerate() on authentication. With saveUninitialized: false, no session is persisted until you call req.session.save() — so a pre-auth session init request returns no persistent session ID to fixate on. With regenerate(), even if the server somehow issues a pre-auth ID, it's invalidated the moment authentication succeeds.
Attack Scenario 2: Session token hijacking via MCP HTTP transport without TLS
Session tokens stolen from plaintext HTTP transport
MCP servers that use HTTP transport and don't enforce TLS transmit session tokens in the clear. Any network position between the LLM client and the MCP server — a Wi-Fi access point, a corporate proxy, an ARP-poisoning attacker on the same LAN — can read the Authorization header from every tool call request.
- Agent connects to MCP server at
http://mcp.internal:3000/(no TLS) - Every tool call includes
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...in plaintext HTTP headers - Network attacker captures the bearer token from any one of the hundreds of tool-call requests in a long session
- Attacker replays the captured token against the MCP server, making tool calls under the victim agent's session for the remaining session lifetime
The vulnerable pattern is an MCP HTTP server that listens on plain HTTP and sends session tokens in Authorization headers:
// VULNERABLE: HTTP-only transport with bearer token authentication
import express from "express";
const app = express();
// No TLS — tokens visible to any network observer
app.listen(3000, "0.0.0.0", () => {
console.log("MCP server listening on http://0.0.0.0:3000");
});
// Tool call handler reads session token from Authorization header over plain HTTP
app.post("/mcp/call", (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing bearer token" });
}
const token = authHeader.slice(7);
// Token was transmitted in cleartext — any network observer has it now
const session = validateToken(token);
if (!session) {
return res.status(401).json({ error: "Invalid token" });
}
// Execute tool call with session context...
});
The fix involves three layers: TLS enforcement at the server, HttpOnly+Secure flags on session cookies (when using cookie-based sessions), and a middleware check that refuses non-HTTPS connections:
// SECURE: enforce TLS, use HttpOnly+Secure cookies, reject plain HTTP
import https from "https";
import fs from "fs";
import express from "express";
import session from "express-session";
const app = express();
// Middleware: redirect HTTP to HTTPS (or reject entirely for non-browser clients)
app.use((req, res, next) => {
// x-forwarded-proto is set by load balancers/reverse proxies (nginx, caddy, etc.)
const proto = req.headers["x-forwarded-proto"] || req.protocol;
if (proto !== "https") {
// For API clients that don't follow redirects, reject outright
return res.status(426).json({
error: "TLS required",
message: "MCP server requires HTTPS. Reconnect using https://",
});
}
next();
});
// Session cookies with all security flags
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
genid: () => require("crypto").randomBytes(32).toString("hex"),
cookie: {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: "strict", // No cross-site requests
maxAge: 4 * 60 * 60 * 1000, // 4-hour max session
}
}));
// Create HTTPS server with valid TLS certificate
const server = https.createServer({
key: fs.readFileSync(process.env.TLS_KEY_PATH!),
cert: fs.readFileSync(process.env.TLS_CERT_PATH!),
// Enforce modern TLS versions only
minVersion: "TLSv1.2",
ciphers: [
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256",
"TLS_AES_128_GCM_SHA256",
"ECDHE-RSA-AES256-GCM-SHA384",
].join(":"),
}, app);
server.listen(443, "0.0.0.0");
For MCP servers deployed behind a reverse proxy (nginx, Caddy, or a cloud load balancer), TLS termination at the proxy is acceptable — but only if the connection between the proxy and the MCP server is either on a trusted local network or also TLS-encrypted. A common mistake is terminating TLS at the load balancer and then forwarding to the MCP server over plain HTTP on an internal network that is assumed to be trusted but isn't.
Attack Scenario 3: Concurrent agent sessions sharing mutable state without locking
Race condition via two agent instances sharing a session
In multi-agent deployments, two agent instances may share a session token — either deliberately (a pool of agents working on the same task) or by accident (a leaked token used by an attacker alongside the legitimate agent). Both instances modify session state concurrently, causing race conditions that corrupt the session or allow privilege escalation.
- Agent A and Agent B both hold session token
sess_abc123. Agent A is legitimate; Agent B is an attacker or a second agent instance. - Agent A reads session state:
{ role: "readonly", allowedPaths: ["/data/reports/"] } - Agent B simultaneously writes to session state, upgrading role:
{ role: "admin", allowedPaths: ["/"] } - Agent A's next tool call reads the session state that Agent B wrote — A now executes with elevated permissions it was never granted
The root cause is mutable shared session state without per-request locking:
// VULNERABLE: no locking on concurrent session reads/writes
app.post("/mcp/call", async (req, res) => {
const sessionId = getSessionId(req);
// Two concurrent requests with the same sessionId race here
const sessionData = await redis.get(`session:${sessionId}`);
const session = JSON.parse(sessionData);
// Between this read and the write below, another request may modify the session
session.lastActivity = Date.now();
session.callCount = (session.callCount || 0) + 1;
// TOCTOU: session may have been modified by a concurrent request
await redis.set(`session:${sessionId}`, JSON.stringify(session));
// Execute tool with potentially stale or corrupted session state
const result = await executeTool(req.body.tool, req.body.args, session);
res.json(result);
});
The fix is two-fold: use per-request optimistic locking (Redis WATCH/MULTI) or agent-bound sessions that are cryptographically tied to the agent instance identity so they cannot be shared across instances:
// SECURE: per-request session locking with Redis WATCH/MULTI
import { createClient } from "redis";
import crypto from "crypto";
const redis = createClient({ url: process.env.REDIS_URL });
async function updateSessionWithLock(
sessionId: string,
updateFn: (session: Record<string, unknown>) => Record<string, unknown>,
maxRetries = 5
): Promise<Record<string, unknown>> {
const key = `session:${sessionId}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
// WATCH the key — if it changes before EXEC, transaction aborts
await redis.watch(key);
const rawSession = await redis.get(key);
if (!rawSession) {
await redis.unwatch();
throw new Error("Session not found");
}
const session = JSON.parse(rawSession) as Record<string, unknown>;
const updated = updateFn(session);
// MULTI/EXEC — atomic only if the key hasn't changed since WATCH
const pipeline = redis.multi();
pipeline.set(key, JSON.stringify(updated), { EX: 4 * 60 * 60 });
const results = await pipeline.exec();
if (results !== null) {
// Transaction succeeded — no concurrent modification
return updated;
}
// Transaction aborted due to concurrent write — retry
await new Promise(r => setTimeout(r, 10 * (attempt + 1)));
}
throw new Error("Session update failed after max retries (concurrent modification)");
}
// Agent-bound sessions: cryptographically bind session to agent instance
function createAgentBoundSession(agentId: string, sessionSecret: string): string {
// Session ID is HMAC(agentId + nonce, secret) — cannot be reused by a different agent
const nonce = crypto.randomBytes(16).toString("hex");
const hmac = crypto.createHmac("sha256", sessionSecret)
.update(`${agentId}:${nonce}`)
.digest("hex");
return `${nonce}.${hmac}`;
}
function verifyAgentBoundSession(
sessionToken: string,
agentId: string,
sessionSecret: string
): boolean {
const [nonce, hmac] = sessionToken.split(".");
if (!nonce || !hmac) return false;
const expected = crypto.createHmac("sha256", sessionSecret)
.update(`${agentId}:${nonce}`)
.digest("hex");
// Timing-safe comparison prevents oracle attacks
return crypto.timingSafeEqual(Buffer.from(hmac, "hex"), Buffer.from(expected, "hex"));
}
// Tool call handler with agent identity verification
app.post("/mcp/call", async (req, res) => {
const sessionToken = getSessionToken(req);
const agentId = req.headers["x-agent-id"] as string;
// Reject if the session token doesn't match this agent's identity
if (!verifyAgentBoundSession(sessionToken, agentId, process.env.SESSION_SECRET!)) {
return res.status(401).json({ error: "Session not bound to this agent instance" });
}
const session = await updateSessionWithLock(getSessionId(sessionToken), (s) => ({
...s,
lastActivity: Date.now(),
callCount: ((s.callCount as number) || 0) + 1,
}));
const result = await executeTool(req.body.tool, req.body.args, session);
res.json(result);
});
Session ID generation: the foundation of session security
All of the above attacks become easier if session IDs are predictable. If an attacker can guess or enumerate session IDs, they don't need to intercept traffic — they can brute-force their way into active sessions. The Node.js standard for generating cryptographically unpredictable session IDs is crypto.randomBytes():
import crypto from "crypto";
// CORRECT: 256 bits of cryptographic randomness (32 bytes = 64 hex chars)
function generateSessionId(): string {
return crypto.randomBytes(32).toString("hex");
}
// WRONG: sequential IDs — trivially enumerable
let counter = 0;
function badSessionId(): string {
return `session_${++counter}`; // session_1, session_2, session_3...
}
// WRONG: timestamp-based — guessable within millisecond precision
function badTimestampId(): string {
return `sess_${Date.now()}_${Math.random()}`; // Math.random() is NOT cryptographically secure
}
// WRONG: UUID v1 — includes MAC address and timestamp, partially predictable
import { v1 as uuidv1 } from "uuid";
function badUUIDv1(): string {
return uuidv1(); // Time-based: sequence is predictable if MAC address is known
}
// CORRECT: UUID v4 is acceptable but crypto.randomBytes is preferred
import { v4 as uuidv4 } from "uuid";
function acceptableUUIDv4(): string {
return uuidv4(); // Random, uses crypto.getRandomValues internally in modern Node.js
}
// BEST PRACTICE: never accept user-supplied session IDs
function initSession(req: Request, res: Response): string {
// If the client sends a session ID in the request, IGNORE it
// Always generate server-side
const sessionId = generateSessionId();
res.setHeader("Set-Cookie", `sessionId=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=14400`);
return sessionId;
}
The rule against user-controlled session IDs is worth emphasizing: if the MCP server accepts a session ID supplied by the client rather than generating one server-side, the server has no control over ID uniqueness or unpredictability. An attacker can supply any ID they choose, which is exactly the session fixation primitive.
Session attack comparison table
| Attack Type | Attack Vector | LLM Agent Amplifier | Primary Fix | Secondary Fix | SkillAudit Detection |
|---|---|---|---|---|---|
| Session fixation | Attacker obtains pre-auth session ID; tricks agent into authenticating with it | Agent doesn't detect session ID mismatch; no human to notice the anomaly | session.regenerate() on every authentication event |
saveUninitialized: false prevents pre-auth ID issuance |
Static analysis: checks for saveUninitialized: true without regenerate() call on auth path |
| Network sniffing (no TLS) | Bearer token or cookie captured from plaintext HTTP | Hundreds of tool calls per session = hundreds of opportunities to capture | Enforce TLS (HTTPS only, reject HTTP with 426) | HttpOnly + Secure + SameSite=Strict cookie flags | Checks for http:// listen calls without TLS config; missing Secure cookie flag |
| Token theft via XSS | Companion web UI stores token in localStorage or JavaScript-accessible cookie | Agent session tokens are more valuable than user tokens — much longer-lived authority | HttpOnly cookies (JS cannot read); never store tokens in localStorage | Content-Security-Policy header to reduce XSS exposure | Checks for missing httpOnly: true in session config; token storage in client-side code |
| Timing attack on session lookup | Non-constant-time session ID comparison leaks validity information | High request rate of agent sessions provides more timing samples | crypto.timingSafeEqual() for all token comparisons |
Rate-limit session validation endpoint | Flags direct string equality (===) on session tokens in auth middleware |
| Predictable session ID | Sequential or timestamp-based IDs enumerated or guessed | Long session lifetime gives more time for brute-force | crypto.randomBytes(32).toString("hex") |
Never accept client-supplied session IDs | Detects Math.random(), Date.now(), uuid/v1, counter-based ID generation |
| Concurrent session race | Two agent instances share a token; race on mutable session state | High tool-call frequency makes TOCTOU windows easy to hit | Redis WATCH/MULTI or per-request mutex | Agent-bound sessions: cryptographically bind token to agent identity | Flags non-atomic Redis read-modify-write patterns in session update handlers |
Session expiry and invalidation: preventing infinite lifetime sessions
Long-lived agent sessions need explicit expiry policies. A session with no expiry is a permanent credential — once issued, it grants access until explicitly revoked. For agent sessions that span hours, a reasonable expiry is 2–8 hours from creation (not from last activity), because activity-based renewal means an attacker who hijacks a session can keep it alive indefinitely just by sending heartbeat calls.
// Session expiry policy for LLM agent contexts
interface AgentSession {
sessionId: string;
userId: string;
agentId: string;
createdAt: number; // Absolute creation time — session dies at createdAt + maxAge
lastActivity: number;
callCount: number;
authenticated: boolean;
}
const SESSION_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours absolute maximum
const SESSION_IDLE_MAX_MS = 30 * 60 * 1000; // 30 min idle timeout
function isSessionValid(session: AgentSession): { valid: boolean; reason?: string } {
const now = Date.now();
// Absolute expiry — no renewal allowed past this point
if (now - session.createdAt > SESSION_MAX_AGE_MS) {
return { valid: false, reason: "session_expired_absolute" };
}
// Idle expiry — session unused for 30 minutes
if (now - session.lastActivity > SESSION_IDLE_MAX_MS) {
return { valid: false, reason: "session_expired_idle" };
}
if (!session.authenticated) {
return { valid: false, reason: "session_not_authenticated" };
}
return { valid: true };
}
// Invalidation on agent context reset
app.post("/mcp/session/reset", async (req, res) => {
const sessionId = getSessionId(req);
// Destroy the session in the store — not just clear client-side cookie
await redis.del(`session:${sessionId}`);
// Issue a new session ID for the reset context (never reuse)
const newSessionId = crypto.randomBytes(32).toString("hex");
await redis.set(`session:${newSessionId}`, JSON.stringify({
sessionId: newSessionId,
createdAt: Date.now(),
lastActivity: Date.now(),
callCount: 0,
authenticated: false,
}), { EX: SESSION_MAX_AGE_MS / 1000 });
res.json({ sessionId: newSessionId, status: "reset" });
});
SkillAudit findings and grade impacts
Production hardening checklist
- Call
session.regenerate()on every authentication event — never reuse a pre-auth session ID after the agent proves its identity - Set
saveUninitialized: false— no session is created until authentication succeeds; no pre-auth ID exists to fixate on - Enforce TLS globally — reject plain HTTP connections with a 426 status; configure strict TLS (minimum TLSv1.2, modern cipher suites)
- Set all cookie security flags —
HttpOnly: true,Secure: true,SameSite: "strict"on all session cookies - Generate session IDs with
crypto.randomBytes(32)— 256 bits of entropy; never useMath.random(),Date.now(), UUID v1, or sequential counters - Never accept client-supplied session IDs — all session IDs must be generated server-side; if the client sends one, ignore it and issue a fresh one
- Set absolute session expiry — 2–8 hours from creation time, not from last activity; agent heartbeats should not reset the clock
- Invalidate sessions on agent context reset — call
redis.del()on the session key; issue a new ID for the new context - Use Redis WATCH/MULTI or mutexes for session state updates — prevent TOCTOU races in multi-agent or high-concurrency deployments
- Cryptographically bind sessions to agent identity — include an HMAC of the agent ID in the session token; reject tokens that don't match the requesting agent
Audit your MCP server's session management. SkillAudit's static analysis traces session creation, authentication, and regeneration paths — flagging missing regenerate() calls, insecure session ID generation, missing TLS enforcement, and race conditions in concurrent session access. Run a free audit on your server →