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
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 →