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

Attack

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.

  1. Attacker sends a request to the MCP server's session-init endpoint and receives a session ID: sessionId = "abc123def456"
  2. Attacker embeds this session ID in a crafted MCP connection string or tricks the agent into connecting with a pre-specified session parameter
  3. Agent authenticates using the attacker's pre-set session ID — the server upgrades the session to authenticated status without regenerating the ID
  4. 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

Attack

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.

  1. Agent connects to MCP server at http://mcp.internal:3000/ (no TLS)
  2. Every tool call includes Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... in plaintext HTTP headers
  3. Network attacker captures the bearer token from any one of the hundreds of tool-call requests in a long session
  4. 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

Attack

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.

  1. 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.
  2. Agent A reads session state: { role: "readonly", allowedPaths: ["/data/reports/"] }
  3. Agent B simultaneously writes to session state, upgrading role: { role: "admin", allowedPaths: ["/"] }
  4. 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

Finding → Grade Impact
Critical Session ID not regenerated after authentication — session fixation attack is trivially possible. −25 points.
Critical Session tokens transmitted over HTTP without TLS — tokens visible to any network observer on every tool call. −22 points.
High No session expiry or infinite session lifetime — a compromised session token remains valid indefinitely. −15 points.
High Predictable session ID generation using UUID v1, Date.now(), Math.random(), or sequential counter — IDs are guessable or enumerable. −12 points.
High Concurrent agent sessions sharing mutable session state without per-request locking — race condition allows privilege escalation. −10 points.
Medium No session invalidation when the agent context is reset — old session remains valid after the agent context it authenticated for no longer exists. −6 points.
Medium Missing Secure or HttpOnly flags on session cookies — token accessible to JavaScript or transmitted over HTTP. −4 points.

Production hardening checklist

  1. Call session.regenerate() on every authentication event — never reuse a pre-auth session ID after the agent proves its identity
  2. Set saveUninitialized: false — no session is created until authentication succeeds; no pre-auth ID exists to fixate on
  3. Enforce TLS globally — reject plain HTTP connections with a 426 status; configure strict TLS (minimum TLSv1.2, modern cipher suites)
  4. Set all cookie security flagsHttpOnly: true, Secure: true, SameSite: "strict" on all session cookies
  5. Generate session IDs with crypto.randomBytes(32) — 256 bits of entropy; never use Math.random(), Date.now(), UUID v1, or sequential counters
  6. 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
  7. Set absolute session expiry — 2–8 hours from creation time, not from last activity; agent heartbeats should not reset the clock
  8. Invalidate sessions on agent context reset — call redis.del() on the session key; issue a new ID for the new context
  9. Use Redis WATCH/MULTI or mutexes for session state updates — prevent TOCTOU races in multi-agent or high-concurrency deployments
  10. 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 →