Cross-site WebSocket hijacking (CSWSH) in MCP servers: origin validation and upgrade protection
Cross-site WebSocket hijacking (CSWSH) is the WebSocket equivalent of CSRF. A malicious page can trigger a WebSocket connection to your MCP server from the victim's browser, using the victim's existing session cookies, and receive all tool responses. Unlike CSRF — which is typically limited to triggering state-changing requests — CSWSH can also read responses, making it a data exfiltration vulnerability. MCP servers that expose a browser-accessible WebSocket transport are directly in scope.
Why browsers are the threat model here
Most MCP servers run in a CLI or agent context — the transport is stdio or a machine-to-machine HTTP connection. CSWSH only applies when your MCP server exposes a WebSocket endpoint that browsers can connect to: a web-based MCP client, an in-browser Claude integration, or a developer tool that opens a WebSocket endpoint on localhost.
The key browser behavior that enables CSWSH: unlike the fetch API and XMLHttpRequest, browsers automatically include cookies and HTTP auth credentials on WebSocket upgrade requests to same-site origins and, without a SameSite cookie policy, to cross-site origins too. The attacker's page can initiate the WebSocket connection; they don't need to steal the cookies first.
- Victim is logged into your MCP web client at
app.example.com, which maintains a session cookiemcp_session=abc123 - Victim visits an attacker-controlled page at
evil.com - Attacker's page runs:
const ws = new WebSocket("wss://app.example.com/mcp"); - Browser sends the WebSocket upgrade request — including
Cookie: mcp_session=abc123— toapp.example.com - If the server doesn't validate the
Originheader, it accepts the connection as authenticated - Attacker's page can now call any MCP tool and read all responses using the victim's session
Defense 1: Origin header allowlist
The primary defense is to validate the Origin header on every WebSocket upgrade request. Reject upgrades from origins not on your allowlist:
// src/ws-transport.ts
import { WebSocketServer, WebSocket } from "ws";
import { IncomingMessage } from "http";
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging.example.com",
// Never include null, *, or undefined
]);
function isOriginAllowed(origin: string | undefined): boolean {
if (!origin) return false; // reject missing Origin header
try {
const parsed = new URL(origin);
// Normalize: drop trailing slash, force lowercase
return ALLOWED_ORIGINS.has(`${parsed.protocol}//${parsed.host}`);
} catch {
return false; // reject malformed Origin
}
}
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req: IncomingMessage, socket, head) => {
const origin = req.headers.origin;
if (!isOriginAllowed(origin)) {
socket.write("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
Do not use origin.endsWith("example.com") — an attacker can register notexample.com and bypass this check. Use an exact match against a Set of permitted origins, after parsing with new URL().
Defense 2: CSRF token on WebSocket upgrade
For localhost MCP servers (common in development tools that expose a local WebSocket), the Origin header alone is insufficient — browsers may send Origin: null for file:// or sandbox iframe origins, and some browser extensions can forge Origin headers. Add a nonce-based CSRF token to the WebSocket handshake:
// Server: generate a per-session nonce and send it in the page HTML
// <script>window.__MCP_NONCE__ = "{{ nonce }}"</script>
// (rendered server-side; attacker's page cannot read it due to SOP)
// Client:
const ws = new WebSocket(
`wss://localhost:3000/mcp?csrf=${window.__MCP_NONCE__}`
);
// Server: validate nonce on upgrade
server.on("upgrade", (req, socket, head) => {
const params = new URLSearchParams(req.url?.split("?")[1] ?? "");
const csrf = params.get("csrf");
if (!validateCsrfToken(req, csrf)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
// ... proceed with upgrade
});
Defense 3: Per-message authentication (defense in depth)
Even with Origin validation and CSRF tokens, treat WebSocket connections as unauthenticated channels that must re-authenticate at the protocol level. Don't rely solely on the upgrade handshake for auth:
// First message on every WebSocket connection must be an auth frame
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
let authenticated = false;
const authTimeout = setTimeout(() => {
if (!authenticated) {
ws.close(4001, "Authentication timeout");
}
}, 5_000);
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
if (!authenticated) {
if (message.type !== "auth" || !verifyJwt(message.token)) {
ws.close(4003, "Unauthorized");
clearTimeout(authTimeout);
return;
}
authenticated = true;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: "auth_ok" }));
return;
}
// Only reach here after successful auth
handleMcpMessage(ws, message);
});
});
Per-message auth means that even if an attacker successfully hijacks a WebSocket connection (Origin bypass + cookie), they still can't call any MCP tools without a valid JWT that they'd need to steal separately — a much harder attack.
SameSite cookie configuration
Set SameSite=Strict or SameSite=Lax on your session cookies. This prevents browsers from sending cookies on cross-site WebSocket upgrade requests entirely, eliminating the fundamental CSWSH precondition:
// Express session cookie setup
app.use(session({
secret: process.env.SESSION_SECRET!,
cookie: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: "strict", // Never sent on cross-site requests
maxAge: 3600_000,
},
}));
Note: SameSite=Lax protects against CSWSH but allows cookies on top-level cross-site GET navigations. SameSite=Strict blocks cookies on all cross-site requests including those navigations — use Strict for session cookies on MCP server admin interfaces where users won't navigate directly from external links.
SkillAudit findings for CSWSH
Origin header — any origin can initiate authenticated connections using victim's session cookiesSameSite attribute — cookies sent on cross-site WebSocket upgrade requestsendsWith) — bypassable with a lookalike domain (notexample.com)Origin: null bypass possible in some browser contextsSkillAudit checks for Origin header validation in WebSocket upgrade handlers and SameSite on session cookies as part of the Security axis assessment. Run a free audit to see if your MCP server's WebSocket transport is vulnerable to CSWSH.