Topic: mcp server websocket auth security
MCP server WebSocket authentication security — auth on upgrade, session binding, and message-level tokens
MCP servers that use WebSocket transports (the SSE+WS transport, or direct WebSocket connections for streaming tool responses) introduce authentication challenges absent from stateless HTTP APIs. The WebSocket protocol upgrades an HTTP connection and then maintains it indefinitely — auth that was checked at upgrade time can be stale, and auth not checked until after the connection is established creates a window of unauthorized access. The three patterns below cover the most impactful WebSocket authentication hardening steps.
Pattern 1: Deferring auth to after connect — the post-handshake auth window
The WebSocket upgrade flow starts as an HTTP GET with an Upgrade: websocket header. The server has a choice: reject the upgrade with a 4xx response (no connection established), or accept the upgrade and then authenticate on the first message. Servers that accept first and authenticate second create a window during which the connection is fully established but the caller's identity has not been verified. In that window, the client can send tool invocation frames, and a race-condition in the server's message handler may process them before the auth check completes.
WRONG — accepting the upgrade before authenticating
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3000 });
// WRONG: connection accepted unconditionally; auth happens on first message
wss.on('connection', (ws) => {
ws.on('message', async (raw) => {
const msg = JSON.parse(raw);
// WRONG: auth check happens after the connection is open
// A fast client can send multiple messages before this resolves
if (msg.type === 'auth') {
const user = await verifyToken(msg.token);
ws.user = user;
return;
}
if (!ws.user) {
ws.close(4001, 'Not authenticated');
return;
}
await handleToolCall(ws, msg);
});
});
RIGHT — authenticate during the upgrade request, reject unauthenticated connections
import { WebSocketServer } from 'ws';
import http from 'node:http';
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
// RIGHT: intercept the upgrade request and authenticate before accepting
server.on('upgrade', async (req, socket, head) => {
try {
// Token via query param (only option for browser WS — headers aren't supported)
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
const user = await verifyToken(token); // throws if invalid/expired
// RIGHT: attach the verified identity to the request before upgrading
req.authenticatedUser = user;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
} catch {
// RIGHT: authentication failure → refuse the upgrade, destroy the socket
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
}
});
wss.on('connection', (ws, req) => {
// RIGHT: ws.user is always set here — no unauthenticated connections reach this handler
ws.user = req.authenticatedUser;
ws.on('message', (raw) => handleToolCall(ws, JSON.parse(raw)));
});
Pattern 2: Missing session binding — connection hijacking via token theft
A WebSocket token passed in the upgrade query string can be stolen from server logs, proxy logs, browser history, or referer headers. An attacker with the token can open a parallel WebSocket connection and impersonate the victim — both connections will authenticate successfully. The original user's session and the attacker's session will share the same server-side context with no way to distinguish them.
Session binding ties a WebSocket connection to a specific client instance using a combination of the auth token and a per-connection nonce or device fingerprint. The simplest approach is to generate a cryptographically random connectionId on the server at upgrade time and include it as an HTTP-only cookie on the handshake response — the cookie cannot be read by JavaScript and is bound to the origin, preventing a token-replay attack from a different origin.
WRONG — same token accepted from any connection
// WRONG: token accepted from any WebSocket connection without binding
// Stolen token = attacker can open a valid session anywhere
server.on('upgrade', async (req, socket, head) => {
const token = new URL(req.url, 'http://x').searchParams.get('token');
const user = await verifyToken(token);
// No binding — this user token works from any IP, any device
req.user = user;
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
});
RIGHT — bind the connection with a server-generated nonce stored in HttpOnly cookie
import crypto from 'node:crypto';
// RIGHT: active connections map: connectionId → { user, createdAt }
const activeSessions = new Map();
server.on('upgrade', async (req, socket, head) => {
try {
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
const user = await verifyToken(token);
// RIGHT: generate a per-connection ID — binds this upgrade to a specific socket
const connectionId = crypto.randomBytes(32).toString('hex');
// RIGHT: write the connectionId as an HttpOnly, Secure, SameSite=Strict cookie
// Browser WS clients will include this on the upgrade; stolen tokens without
// the matching cookie cannot establish a session
const cookieHeader = [
`ws_cid=${connectionId}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
'Path=/',
`Max-Age=300`, // 5-minute window to complete the handshake
].join('; ');
activeSessions.set(connectionId, {
user,
createdAt: Date.now(),
});
// Pass custom headers to the upgrade response
wss.handleUpgrade(req, socket, head, (ws) => {
ws.connectionId = connectionId;
ws.user = user;
// Clean up on close
ws.once('close', () => activeSessions.delete(connectionId));
wss.emit('connection', ws, req);
});
} catch {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
}
});
Pattern 3: No token re-validation — using a revoked token for the connection lifetime
A WebSocket connection can stay open for hours or days. An auth token validated at upgrade time may expire, may be revoked (because the user changed their password, logged out, or was suspended), or may belong to a user whose permissions changed. A server that does not re-validate the token during the connection lifetime will keep executing tool calls for a session that should no longer be authorized.
The fix is to re-validate the token on a periodic heartbeat or on each message, with a configurable re-validation interval. Re-validation on each message adds latency but is the most secure option — a token revoked between two messages will be caught before the second message is processed. A heartbeat approach (re-validate every 5 minutes) is a reasonable tradeoff for long-running connections that do not require immediate revocation response.
RIGHT — periodic token re-validation with connection termination on failure
const TOKEN_REVALIDATION_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
wss.on('connection', (ws, req) => {
ws.user = req.authenticatedUser;
ws.token = req.authToken; // stash the original token for re-validation
// RIGHT: schedule periodic re-validation for the lifetime of the connection
const revalidationTimer = setInterval(async () => {
try {
// Re-fetch the user — this also catches permission changes, not just expiry
const currentUser = await verifyToken(ws.token);
ws.user = currentUser; // update with latest permissions
} catch {
// RIGHT: token is invalid or revoked — close the connection gracefully
ws.send(JSON.stringify({
type: 'error',
code: 'SESSION_EXPIRED',
message: 'Your session has expired. Please reconnect.',
}));
ws.close(4003, 'Session expired');
clearInterval(revalidationTimer);
}
}, TOKEN_REVALIDATION_INTERVAL_MS);
// RIGHT: clean up the timer when the connection closes naturally
ws.once('close', () => clearInterval(revalidationTimer));
ws.on('message', (raw) => handleToolCall(ws, JSON.parse(raw)));
});