MCP Server Security
Session fixation security for MCP servers
MCP servers with HTTP transport and session management are vulnerable to session fixation attacks if they reuse session IDs across authentication state changes. This guide covers the specific failure patterns, crypto.randomUUID() vs predictable ID generation, and the session binding techniques that protect against fixation and hijacking.
What session fixation means in MCP context
Session fixation occurs when an attacker can set or predict a session ID before authentication, then use that same ID after the victim authenticates to inherit their session. In MCP servers with HTTP transport, this manifests when the session ID issued before auth is preserved through the auth flow.
The attack: attacker sends victim a crafted URL containing a session ID the attacker chose. Victim authenticates. Server keeps the same session ID. Attacker uses the pre-shared session ID to access the authenticated session.
MCP-specific risk: a compromised session gives the attacker the ability to issue tool calls under the victim's identity — reading files, executing actions, accessing downstream services.
Session ID regeneration after authentication
The fix is mandatory regeneration of the session ID immediately after a successful authentication event. The old session ID is destroyed; a new cryptographically random one is issued.
import express from 'express'
import session from 'express-session'
import { randomUUID } from 'crypto'
const app = express()
app.use(session({
genid: () => randomUUID(), // cryptographic ID generation
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 4 * 60 * 60 * 1000 // 4 hours
}
}))
app.post('/auth/callback', async (req, res) => {
const user = await verifyOAuthCallback(req.query)
if (!user) return res.status(401).send('Auth failed')
// Regenerate session ID BEFORE storing any auth data
await new Promise((resolve, reject) =>
req.session.regenerate(err => err ? reject(err) : resolve())
)
req.session.userId = user.id
req.session.authenticatedAt = Date.now()
res.redirect('/dashboard')
})
crypto.randomUUID() vs predictable session IDs
Session IDs must be generated with a cryptographically secure random number generator. Predictable IDs — sequential integers, timestamps, Math.random()-based values — allow an attacker to enumerate or brute-force valid sessions.
// Predictable — never use these for session IDs
const bad1 = Date.now().toString() // timestamp: 14 digits, predictable
const bad2 = Math.random().toString(36) // Math.random is not CSPRNG
const bad3 = (++counter).toString() // sequential: trivially enumerable
// Cryptographically secure — use these
const good1 = randomUUID() // 122 bits entropy, RFC 4122 v4
const good2 = randomBytes(32).toString('hex') // 256 bits entropy
// For the MCP StreamableHTTP transport's sessionIdGenerator:
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { randomUUID } from 'crypto'
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID() // explicit CSPRNG — never omit this
})
Session binding to IP and User-Agent
Binding a session to the client IP address and User-Agent header adds a second layer of validation. An attacker who steals a session cookie must also present the same IP and User-Agent to use it. This is not a complete defence (an attacker on the same network can match both), but it raises the bar significantly against session token theft from logs or XSS.
// Store fingerprint at session creation
app.post('/auth/callback', async (req, res) => {
const user = await verifyOAuthCallback(req.query)
await new Promise((r, j) => req.session.regenerate(e => e ? j(e) : r()))
req.session.userId = user.id
req.session.fingerprint = {
ip: req.ip,
ua: req.get('user-agent') ?? ''
}
res.redirect('/dashboard')
})
// Validate fingerprint on every authenticated request
function requireAuth(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: 'Unauthorized' })
const fp = req.session.fingerprint
if (fp) {
const currentIp = req.ip
const currentUa = req.get('user-agent') ?? ''
if (fp.ip !== currentIp || fp.ua !== currentUa) {
req.session.destroy()
return res.status(401).json({ error: 'Session invalid' })
}
}
next()
}
Session lifecycle: creation, expiry, and destruction
Beyond fixation, session security depends on tight lifecycle management: short max-age for sensitive operations, absolute expiry independent of activity, and explicit destruction on logout.
// Absolute expiry — prevents sessions that live indefinitely via activity
function requireAuth(req, res, next) {
if (!req.session.userId) return res.status(401).end()
const MAX_SESSION_AGE_MS = 4 * 60 * 60 * 1000 // 4 hours absolute
if (Date.now() - req.session.authenticatedAt > MAX_SESSION_AGE_MS) {
req.session.destroy()
return res.status(401).json({ error: 'Session expired — re-authenticate' })
}
next()
}
// Explicit destruction on logout
app.post('/auth/logout', (req, res) => {
req.session.destroy(err => {
res.clearCookie('connect.sid')
res.redirect('/')
})
})
What SkillAudit flags
- Session ID not regenerated after auth — High (direct session fixation vector)
- Math.random() or timestamp-based session IDs — High (predictable session enumeration)
- Session cookies without
httpOnlyandsecure— High (XSS theft / network interception) - No absolute session expiry — Medium (indefinite session lifetime after token theft)
- Session not destroyed on logout — Medium (session reuse after logout)
- StreamableHTTP transport missing
sessionIdGenerator— Medium (may default to predictable IDs)
Audit your MCP server session management
SkillAudit checks for session fixation vectors, predictable IDs, and missing lifecycle controls. Free graded report in 60 seconds.
Run a free audit →