Topic: WebSocket transport security

MCP server WebSocket transport security — origin validation, message size limits, and Bearer token auth handshake

When an MCP server moves from stdio to WebSocket transport, the attack surface widens significantly. Any browser tab, cross-origin script, or network-adjacent process can attempt to open a WebSocket connection. The HTTP upgrade handshake is the only synchronous gate before the persistent connection is established — and most MCP server implementations skip the checks that matter. Five patterns that harden a WebSocket-based MCP server from upgrade request to message handling.

1. Origin header validation before the HTTP upgrade

Browsers enforce the same-origin policy for most HTTP requests but not for WebSocket upgrades — a page on evil.com can attempt a WebSocket connection to localhost:3000 and the browser will send the request. The server's only defense is validating the Origin header on the incoming upgrade request. If the origin is not in your explicit allowlist, return HTTP 403 before the upgrade completes. This check must happen in the HTTP upgrade handler, not in message processing — by the time your application receives a message, the persistent connection is already open.

import { WebSocketServer } from 'ws'
import { createServer } from 'http'

const ALLOWED_ORIGINS = new Set([
  'https://claude.ai',
  'https://app.example.com',
  // Never include 'null' — that matches file:// origins
])

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

httpServer.on('upgrade', (req, socket, head) => {
  const origin = req.headers['origin'] ?? ''

  // Reject missing or disallowed origins before upgrade
  if (!ALLOWED_ORIGINS.has(origin)) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
    socket.destroy()
    return
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req)
  })
})

httpServer.listen(3000)

2. Per-message size limits to prevent memory exhaustion

The ws library buffers incoming message frames in memory before emitting the message event. Without a size limit, a single connection can send a multi-gigabyte frame and exhaust server memory — crashing the process or triggering OOM killing. Set maxPayload at the WebSocketServer constructor level so the limit is enforced in the native layer before any JavaScript executes. For MCP JSON-RPC messages, 1 MB (1,048,576 bytes) is a reasonable ceiling — legitimate tool calls and responses are measured in kilobytes, not megabytes. Also apply a secondary check for fragmented messages that arrive in multiple frames.

const MAX_MESSAGE_BYTES = 1_048_576  // 1 MiB — generous for JSON-RPC

const wss = new WebSocketServer({
  noServer: true,
  // Enforced at the native ws layer — before any JS allocation
  maxPayload: MAX_MESSAGE_BYTES,
})

wss.on('connection', (ws) => {
  ws.on('error', (err) => {
    // ws emits 'error' with code 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
    // when maxPayload is exceeded — close cleanly
    if ((err as any).code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH') {
      ws.close(1009, 'Message too large')
    }
  })

  ws.on('message', (data, isBinary) => {
    // Secondary guard: reject binary frames (MCP uses text JSON-RPC)
    if (isBinary) {
      ws.close(1003, 'Binary messages not supported')
      return
    }

    const text = data.toString('utf8')
    // Final sanity check on string length after UTF-8 decode
    if (text.length > MAX_MESSAGE_BYTES) {
      ws.close(1009, 'Message too large')
      return
    }

    handleMessage(ws, text)
  })
})

3. Heartbeat ping/pong for zombie connection detection

A WebSocket connection that drops due to network failure or client crash does not send a close frame — the TCP connection simply goes silent. The server has no way to distinguish a slow client from a dead one without an explicit liveness check. WebSocket ping/pong frames exist for exactly this purpose: the server sends a ping and expects a pong within a timeout window. Connections that miss the pong deadline are terminated. Without this, a server that has been running for days can accumulate hundreds of zombie connections, each holding memory for buffered messages and connection state.

const PING_INTERVAL_MS = 30_000   // Send ping every 30 seconds
const PONG_TIMEOUT_MS  = 10_000   // Close if pong not received within 10 seconds

wss.on('connection', (ws) => {
  let pongReceived = true  // Optimistic: treat new connection as alive

  ws.on('pong', () => { pongReceived = true })

  const heartbeat = setInterval(() => {
    if (!pongReceived) {
      // Previous ping got no pong — connection is dead
      clearInterval(heartbeat)
      ws.terminate()  // terminate() vs close(): no close frame, immediate cleanup
      return
    }
    pongReceived = false
    ws.ping()
  }, PING_INTERVAL_MS)

  ws.on('close', () => clearInterval(heartbeat))
  ws.on('error', () => {
    clearInterval(heartbeat)
    ws.terminate()
  })
})

4. Bearer token authentication via first application message

WebSocket upgrade URLs — including any query parameters — appear in HTTP access logs, browser navigation history, referrer headers sent to third-party scripts, and any network logging infrastructure between the client and server. A Bearer token in a query parameter like ?token=eyJ... is a credential that will be logged in plaintext. The correct pattern is to complete the upgrade with no credentials in the URL, then require the client to send an authentication message as the very first WebSocket frame. The server holds the connection in an unauthenticated state and rejects any non-auth message until authentication succeeds or a timeout fires.

import { z } from 'zod'
import jwt from 'jsonwebtoken'

const AUTH_TIMEOUT_MS = 5_000  // Client must auth within 5 seconds of connect

const AuthMessageSchema = z.object({
  type: z.literal('auth'),
  token: z.string().min(10).max(2048),
})

wss.on('connection', (ws) => {
  let authenticated = false

  // Kill unauthenticated connections that never send auth
  const authTimeout = setTimeout(() => {
    if (!authenticated) ws.close(4401, 'Authentication timeout')
  }, AUTH_TIMEOUT_MS)

  ws.once('message', (data) => {
    clearTimeout(authTimeout)

    let msg: unknown
    try { msg = JSON.parse(data.toString()) }
    catch { ws.close(4400, 'Invalid JSON in auth message'); return }

    const result = AuthMessageSchema.safeParse(msg)
    if (!result.success) { ws.close(4400, 'Invalid auth message schema'); return }

    try {
      const payload = jwt.verify(result.data.token, process.env.JWT_SECRET!)
      authenticated = true
      ws.send(JSON.stringify({ type: 'auth_ok' }))
      // Now register the real message handler
      ws.on('message', (d) => handleAuthenticatedMessage(ws, d, payload))
    } catch {
      ws.close(4401, 'Invalid token')
    }
  })
})

5. Zod message schema validation for every inbound frame

After authentication, every inbound WebSocket frame must be validated against the MCP JSON-RPC schema before dispatch. There is no HTTP middleware chain, no content-type enforcement, and no request routing framework between the raw WebSocket frame and your tool handler. The message event fires with a raw Buffer. Without schema validation at this boundary, malformed messages — missing id fields, unexpected method values, arguments of the wrong type — reach your tool logic and cause unpredictable behavior. Define a strict Zod schema that covers every valid MCP message type and reject anything that does not match.

import { z } from 'zod'

// MCP JSON-RPC message schema — strict, no extra keys
const McpRequestSchema = z.object({
  jsonrpc: z.literal('2.0'),
  id: z.union([z.string(), z.number()]).optional(),
  method: z.string().min(1).max(100),
  params: z.record(z.unknown()).optional(),
}).strict()

const McpNotificationSchema = McpRequestSchema.extend({
  id: z.undefined(),
})

const InboundMessageSchema = z.union([McpRequestSchema, McpNotificationSchema])

function handleAuthenticatedMessage(ws: WebSocket, data: Buffer, principal: unknown) {
  let raw: unknown
  try {
    raw = JSON.parse(data.toString('utf8'))
  } catch {
    // Malformed JSON: send JSON-RPC parse error
    ws.send(JSON.stringify({ jsonrpc: '2.0', id: null,
      error: { code: -32700, message: 'Parse error' } }))
    return
  }

  const result = InboundMessageSchema.safeParse(raw)
  if (!result.success) {
    ws.send(JSON.stringify({ jsonrpc: '2.0', id: (raw as any)?.id ?? null,
      error: { code: -32600, message: 'Invalid Request' } }))
    return
  }

  // Safe to dispatch — schema guarantees shape
  dispatchMcpMethod(ws, result.data, principal)
}

How WebSocket security maps to SkillAudit sub-scores

WebSocket transport vulnerabilities surface across multiple SkillAudit dimensions because they span authentication, denial-of-service, and input validation concerns:

For the HTTP transport equivalent of these patterns, see MCP server TLS configuration security. For authentication patterns that apply across transport types, see MCP server access control.

Audit your WebSocket MCP server's transport security

SkillAudit checks for missing origin validation, credential exposure in URLs, and absent schema validation across all MCP transport types.

See pricing