Topic: WebSocket transport security in MCP servers

WebSocket Transport Security in MCP Servers

HTTP-mode MCP servers that expose a WebSocket endpoint face a distinct threat surface compared to stdio-mode servers. Unlike HTTP requests, WebSocket connections are persistent and bidirectional — once established, any message can flow in either direction without re-authentication. The three most common WebSocket security failures in MCP servers are: using ws:// (plaintext) in production, skipping origin validation (enabling cross-origin WebSocket hijacking), and accepting connections without an authentication step on the first message.

ws:// vs wss:// — why plaintext WebSocket is a hard fail

The ws:// scheme transmits all WebSocket frames in plaintext. On any network path with a man-in-the-middle — corporate proxy, shared Wi-Fi, or a compromised router — an attacker can read every JSON-RPC message exchanged between the MCP client and server, and inject arbitrary frames into the stream. For an MCP server, this means the attacker can read tool arguments (which may contain sensitive data the LLM is processing) and inject forged tool responses (which the LLM will treat as legitimate results).

wss:// is WebSocket over TLS — equivalent to HTTPS. The TLS handshake authenticates the server's certificate and establishes an encrypted channel before any WebSocket frames are transmitted. In production, ws:// is never acceptable. During local development, it is acceptable only on localhost (loopback traffic does not traverse a network path).

Origin validation to prevent cross-origin WebSocket hijacking

Browsers enforce the Same-Origin Policy for XMLHttpRequest and Fetch, but WebSocket upgrade requests are not subject to CORS preflight. A malicious web page on evil.example.com can open a WebSocket connection to wss://your-mcp-server.example.com in the visitor's browser, and the browser will include the user's cookies in the upgrade request — allowing the malicious page to make authenticated WebSocket calls to your server on the user's behalf.

The defense is server-side Origin header validation on the WebSocket upgrade request. The browser always sends the Origin header on WebSocket upgrades (it cannot be spoofed from browser context). Non-browser clients (including the MCP SDK) send either no Origin or a configurable one — your allowlist should include both the expected origin and an empty/absent origin for trusted non-browser clients.

import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';

const ALLOWED_ORIGINS = new Set([
  'https://claude.ai',
  'https://app.yourdomain.com',
  // Allow absent Origin for non-browser MCP clients (SDK, CLI tools)
]);

function isOriginAllowed(req: IncomingMessage): boolean {
  const origin = req.headers['origin'];
  // No origin header = non-browser client (MCP SDK, curl, etc.) — allow
  if (!origin) return true;
  return ALLOWED_ORIGINS.has(origin);
}

const wss = new WebSocketServer({ noServer: true });

// Attach to an existing HTTP/HTTPS server to share port and TLS config
httpServer.on('upgrade', (req, socket, head) => {
  if (!isOriginAllowed(req)) {
    // Reject the upgrade — send 403 and destroy the socket
    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);
  });
});

wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
  // Require authentication on the first message within 5 seconds
  let authenticated = false;
  const authTimeout = setTimeout(() => {
    if (!authenticated) {
      ws.close(1008, 'Authentication timeout');
    }
  }, 5000);

  ws.on('message', (data) => {
    if (!authenticated) {
      // First message must be an auth frame: { type: 'auth', token: '...' }
      try {
        const msg = JSON.parse(data.toString());
        if (msg.type === 'auth' && verifyToken(msg.token)) {
          authenticated = true;
          clearTimeout(authTimeout);
          ws.send(JSON.stringify({ type: 'auth_ok' }));
        } else {
          ws.close(1008, 'Invalid credentials');
        }
      } catch {
        ws.close(1007, 'Invalid message format');
      }
      return;
    }
    handleMcpMessage(ws, data);
  });
});

Message size limits to prevent DoS

WebSocket has no built-in message size limit. An attacker (or a misbehaving LLM agent) that can reach the WebSocket endpoint can send a single 2GB message, causing the server to allocate 2GB of memory before it can parse and reject the frame. The ws library exposes a maxPayload option that enforces a per-message byte limit at the parser level — before the payload is buffered into a JavaScript string.

const wss = new WebSocketServer({
  noServer: true,
  // Reject messages larger than 1MB at the parser level
  // The ws library closes the connection with code 1009 (message too large)
  maxPayload: 1 * 1024 * 1024,  // 1 MB
});

// Also enforce a per-connection rate limit to prevent message flooding
function makeRateLimiter(maxMessages: number, windowMs: number) {
  const counts = new Map();
  return (ws: WebSocket): boolean => {
    const now = Date.now();
    const entry = counts.get(ws) ?? { count: 0, resetAt: now + windowMs };
    if (now > entry.resetAt) {
      entry.count = 0;
      entry.resetAt = now + windowMs;
    }
    entry.count++;
    counts.set(ws, entry);
    return entry.count <= maxMessages;
  };
}

const checkRate = makeRateLimiter(100, 10_000);  // 100 messages per 10 seconds

wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    if (!checkRate(ws)) {
      ws.close(1008, 'Rate limit exceeded');
      return;
    }
    // ... handle message
  });
});

Authentication over WebSocket — token in first message vs URL

Two common patterns for WebSocket authentication, and why to prefer the message-based approach:

Token in connection URL (wss://server/?token=abc123) — simple to implement but the token appears in server access logs, browser history, and any proxy's request log. If the server logs WebSocket upgrade URLs (most HTTP servers do), the token is logged in plaintext. This approach is acceptable for very short-lived tokens (under 60 seconds) that are single-use, but not for session tokens.

Token in first message — the connection is established unauthenticated; the client sends an auth frame as its first message; the server enforces a timeout (5–10 seconds) and closes unauthenticated connections. The token never appears in URL logs. This is the preferred approach and is shown in the code example above.

Both approaches are better than relying on cookies for WebSocket auth — cookies do flow in the WebSocket upgrade request, but cookie-based WebSocket auth is the exact mechanism exploited by cross-origin hijacking if origin validation is missing.

What SkillAudit checks

The network security axis checks for WebSocket transport vulnerabilities:

See also

Check your MCP server's WebSocket configuration for origin validation and TLS findings.

Run a free audit → How grading works →