Topic: mcp server session fixation security

MCP server session fixation and hijacking security — regenerate session ID on auth, prevent token theft

Stateful MCP servers that issue session IDs to track authenticated agent contexts are vulnerable to two related attacks. Session fixation allows an attacker to pre-set a session ID before authentication — if the server doesn't regenerate it on login, the attacker's known ID becomes an authenticated session. Session hijacking steals an already-authenticated session token via network interception, XSS in a companion web UI, or predictable token generation.

Session fixation attack flow

Session fixation exploits MCP servers that issue a session ID before the agent authenticates and then reuse that ID after authentication. The attack sequence is: the attacker requests a pre-auth session endpoint and receives a session ID; they then arrange for the agent to authenticate using that pre-set ID (via a crafted connection URL, a prompt injection, or another social engineering path); and once the agent authenticates, the server marks the attacker's known session ID as authenticated, handing the attacker a fully authenticated session without needing to provide credentials.

// VULNERABLE: session created before auth, reused after
import express from "express";
import session from "express-session";

const app = express();
app.use(express.json());

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: true, // Session created immediately — before any auth
  cookie: { httpOnly: true, secure: true },
}));

// Attacker calls this endpoint to get a pre-auth session ID
app.get("/session/init", (req, res) => {
  // req.session.id is already set by express-session middleware
  // This ID is the fixation target
  res.json({ sessionId: req.session.id });
});

app.post("/session/auth", async (req, res) => {
  const { apiKey } = req.body;
  if (!await validateApiKey(apiKey)) {
    return res.status(401).json({ error: "Invalid key" });
  }
  // VULNERABLE: session ID is NOT regenerated on authentication
  // The attacker's pre-obtained ID is now an authenticated session
  req.session.authenticated = true;
  req.session.userId = await getUserByKey(apiKey);
  req.session.save(() => res.json({ status: "authenticated" }));
});

Session ID regeneration fix with express-session

The fix is req.session.regenerate() immediately after successful authentication. This creates a new cryptographically random session ID and stores the authenticated state under the new ID, while destroying the old pre-auth record in the session store. The attacker's fixated ID becomes an expired, unauthenticated session that the store no longer recognizes:

// SECURE: regenerate session ID on authentication
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false, // No session created until explicitly saved — no pre-auth ID to fixate
  genid: () => require("crypto").randomBytes(32).toString("hex"),
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 4 * 60 * 60 * 1000, // 4-hour absolute maximum
  },
}));

app.post("/session/auth", async (req, res) => {
  const { apiKey } = req.body;
  const userId = await validateApiKey(apiKey);
  if (!userId) {
    return res.status(401).json({ error: "Invalid key" });
  }

  // Regenerate: old session ID is destroyed in the store, new random ID created
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "Session regeneration failed" });

    // Store auth state in the NEW session (under the new ID)
    req.session.authenticated = true;
    req.session.userId = userId;
    req.session.createdAt = Date.now();

    req.session.save((saveErr) => {
      if (saveErr) return res.status(500).json({ error: "Session save failed" });
      // Client receives the NEW session ID — attacker's fixated ID is invalid
      res.json({ status: "authenticated", sessionId: req.session.id });
    });
  });
});

Predictable session ID generation — the UUID v1 problem

UUID v1 includes the MAC address of the generating machine and a timestamp, making its bit pattern partially predictable if either piece of information is known. An attacker who can observe one UUID v1 can narrow the search space for concurrent or near-future session IDs significantly. The correct approach is crypto.randomBytes(32) which generates 256 bits of OS-sourced cryptographic entropy with no structure an attacker can exploit:

import crypto from "crypto";
import { v1 as uuidv1, v4 as uuidv4 } from "uuid";

// WRONG: UUID v1 — MAC address + timestamp = partially predictable
console.log(uuidv1()); // e.g., "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
// The timestamp component narrows enumeration to millisecond precision
// The MAC component is often fixed and observable

// ACCEPTABLE: UUID v4 — cryptographically random in modern Node.js
// (uuid package uses crypto.getRandomValues internally since v9)
console.log(uuidv4()); // e.g., "550e8400-e29b-41d4-a716-446655440000"

// BEST: crypto.randomBytes — 256 bits, no structure, no leakage
function generateSessionId(): string {
  return crypto.randomBytes(32).toString("hex");
  // e.g., "a3f8c2e1d9b4f7a0c6e2d8b5f1a9c3e7b0d4a1e6c0f9b2e8a5d3c7f4b0e1d6"
}

// NEVER: user-supplied session IDs
app.post("/session/init-with-client-id", (req, res) => {
  const { sessionId } = req.body; // NEVER use this
  // An attacker can supply any session ID they choose — this is session fixation by design
  // Always generate server-side:
  const serverGeneratedId = generateSessionId();
  res.json({ sessionId: serverGeneratedId });
});

Token transmission security — HTTPS enforcement and HttpOnly cookies

Session tokens transmitted over plain HTTP are visible to any network observer — a Wi-Fi access point, a corporate proxy, an ARP-poisoning attacker on the same subnet. For MCP servers using HTTP transport, every tool call carries the session token in an Authorization header or cookie, giving a network attacker multiple opportunities to capture it during a long agent session:

// SECURE: enforce TLS and use HttpOnly + Secure cookies
import https from "https";
import fs from "fs";
import express from "express";

const app = express();

// Reject plain HTTP connections — send 426 Upgrade Required
app.use((req, res, next) => {
  const proto = req.headers["x-forwarded-proto"] ?? req.protocol;
  if (proto !== "https") {
    return res.status(426).json({
      error: "TLS required",
      upgrade: "Reconnect using https://",
    });
  }
  next();
});

// Session cookies with all security flags
app.use(session({
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  genid: () => crypto.randomBytes(32).toString("hex"),
  cookie: {
    httpOnly: true,    // JavaScript cannot read this cookie — blocks XSS token theft
    secure: true,      // Cookie only sent over HTTPS — blocks network sniffing
    sameSite: "strict", // Not sent on cross-site requests — blocks CSRF
    maxAge: 4 * 60 * 60 * 1000,
  },
}));

// HTTPS server with modern TLS configuration
const httpsServer = https.createServer({
  key: fs.readFileSync(process.env.TLS_KEY_PATH!),
  cert: fs.readFileSync(process.env.TLS_CERT_PATH!),
  minVersion: "TLSv1.2",
  ciphers: "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-RSA-AES256-GCM-SHA384",
}, app);

httpsServer.listen(443, "0.0.0.0");

Session timeout and invalidation

Agent sessions that never expire are permanent credentials. Use an absolute expiry (from creation time, not last activity) to ensure that even an active attacker cannot keep a stolen session alive indefinitely through heartbeat calls:

// Session validation middleware with absolute and idle expiry
const SESSION_ABSOLUTE_MAX_MS = 4 * 60 * 60 * 1000;  // 4 hours from creation
const SESSION_IDLE_MAX_MS = 30 * 60 * 1000;           // 30 min idle timeout

app.use("/mcp", (req, res, next) => {
  const sess = req.session as any;

  if (!sess.authenticated) {
    return res.status(401).json({ error: "Not authenticated" });
  }

  const now = Date.now();

  // Absolute expiry — no renewal past this point even if active
  if (now - sess.createdAt > SESSION_ABSOLUTE_MAX_MS) {
    req.session.destroy(() => {});
    return res.status(401).json({ error: "Session expired (absolute limit)" });
  }

  // Idle expiry — session unused for 30 minutes
  if (now - sess.lastActivity > SESSION_IDLE_MAX_MS) {
    req.session.destroy(() => {});
    return res.status(401).json({ error: "Session expired (idle timeout)" });
  }

  // Update last activity timestamp
  sess.lastActivity = now;
  next();
});

// Explicit invalidation on agent context reset
app.post("/session/reset", (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: "Session destroy failed" });
    // Clear the cookie on the client too
    res.clearCookie("connect.sid");
    res.json({ status: "session_invalidated" });
  });
});

Multi-session agent context isolation

When multiple agent instances are deployed for the same task (parallel agent pools), each instance must have its own session token bound to its identity. Sharing session tokens across agent instances creates race conditions on session state and allows a compromised instance to access another's context. Cryptographically bind the session token to the agent instance ID at creation time:

import crypto from "crypto";

// Agent-bound session: token is HMAC(agentInstanceId + nonce, secret)
// A token issued to agent-A cannot be used by agent-B even if intercepted
function createAgentBoundToken(agentInstanceId: string, secret: string): string {
  const nonce = crypto.randomBytes(16).toString("hex");
  const hmac = crypto.createHmac("sha256", secret)
    .update(`${agentInstanceId}:${nonce}`)
    .digest("hex");
  return `${agentInstanceId}.${nonce}.${hmac}`;
}

function verifyAgentBoundToken(
  token: string,
  claimedAgentId: string,
  secret: string
): boolean {
  const parts = token.split(".");
  if (parts.length !== 3) return false;
  const [embeddedAgentId, nonce, hmac] = parts;

  // The claimed agent ID must match the embedded one
  if (embeddedAgentId !== claimedAgentId) return false;

  const expected = crypto.createHmac("sha256", secret)
    .update(`${embeddedAgentId}:${nonce}`)
    .digest("hex");

  const hmacBuf = Buffer.from(hmac, "hex");
  const expectedBuf = Buffer.from(expected, "hex");

  if (hmacBuf.length !== expectedBuf.length) return false;
  // Timing-safe comparison — prevents oracle attacks on the HMAC
  return crypto.timingSafeEqual(hmacBuf, expectedBuf);
}

// In tool call handler: reject if token doesn't match requesting agent
app.post("/mcp/call", (req, res, next) => {
  const token = req.headers.authorization?.replace("Bearer ", "") ?? "";
  const agentId = req.headers["x-agent-instance-id"] as string;

  if (!verifyAgentBoundToken(token, agentId, process.env.SESSION_SECRET!)) {
    return res.status(401).json({ error: "Token not bound to this agent instance" });
  }
  next();
});

SkillAudit findings and grade impacts

Finding → Grade Impact
Critical Session ID not regenerated after authentication — session fixation attack gives attacker a pre-known authenticated session ID. −25 points.
Critical Session tokens transmitted over HTTP without TLS — bearer tokens visible in plaintext on every tool call to a network observer. −22 points.
High No session expiry — sessions are valid indefinitely; a stolen token provides permanent access. −15 points.
High Predictable session IDs — UUID v1, Date.now(), Math.random(), or sequential counters allow ID enumeration or brute-force. −12 points.
High Shared mutable session state across concurrent agent instances without per-request locking — TOCTOU race allows privilege escalation between agents. −10 points.
Medium No session invalidation on agent context reset — old session remains valid and usable after the agent context it authenticated for is terminated. −6 points.

Audit your MCP server's session management. SkillAudit traces session creation, authentication, and regeneration paths — flagging missing regenerate() calls, insecure ID generation, missing TLS enforcement, and race conditions in concurrent session access. Run a free audit →