Web Security
WebSocket
Real-Time Security
Node.js
CSWSH

WebSocket Security: 6 Vulnerabilities Developers Always Miss

SecureCodeReviews Team
January 20, 2025
13 min read
Share

Why WebSocket Security Gets Overlooked

WebSockets provide real-time, bidirectional communication — powering chat apps, live dashboards, collaborative editors, and trading platforms. But they bypass most traditional HTTP security controls:

  • ❌ No CORS protection (WebSockets don't follow same-origin policy the same way)
  • ❌ No built-in authentication per message
  • ❌ No automatic CSRF protection
  • ❌ WAFs often can't inspect WebSocket traffic
  • ❌ Rate limiting is harder to implement

In our security audits, 90% of WebSocket implementations had at least one critical vulnerability.


Vulnerability #1: Cross-Site WebSocket Hijacking (CSWSH)

This is the WebSocket equivalent of CSRF — and it's critical.

How It Works

  1. User is logged into app.example.com (has session cookie)
  2. User visits evil.com
  3. evil.com opens a WebSocket to app.example.com/ws
  4. Browser sends the session cookie automatically
  5. Attacker's page now has an authenticated WebSocket connection

❌ Vulnerable Server

const WebSocket = require('ws');
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws, req) => {
  // No origin check!
  // Cookie is sent automatically — attacker has authenticated connection
  const session = getSessionFromCookie(req.headers.cookie);
  ws.userId = session.userId;
});

✅ Fixed Server

wss.on('connection', (ws, req) => {
  // Check Origin header
  const origin = req.headers.origin;
  const allowedOrigins = ['https://app.example.com'];

  if (!allowedOrigins.includes(origin)) {
    ws.close(1008, 'Origin not allowed');
    return;
  }

  // Better: Use ticket-based auth instead of cookies
  const ticket = new URL(req.url, 'http://localhost').searchParams.get('ticket');
  const session = validateOneTimeTicket(ticket);

  if (!session) {
    ws.close(1008, 'Invalid ticket');
    return;
  }

  ws.userId = session.userId;
});

Vulnerability #2: No Authentication After Handshake

The initial WebSocket handshake might be authenticated, but subsequent messages aren't verified.

❌ Vulnerable Pattern

wss.on('connection', (ws, req) => {
  const user = authenticateRequest(req); // Auth only at connection time

  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    // No re-verification — what if session expired?
    // What if user's permissions changed?
    handleMessage(ws, msg);
  });
});

✅ Fix: Per-Message Token Validation

wss.on('connection', (ws, req) => {
  let currentUser = authenticateRequest(req);

  ws.on('message', async (data) => {
    const msg = JSON.parse(data);

    // Periodically revalidate (every 5 minutes)
    if (Date.now() - ws.lastAuthCheck > 300000) {
      currentUser = await revalidateSession(ws.sessionId);
      if (!currentUser) {
        ws.close(1008, 'Session expired');
        return;
      }
      ws.lastAuthCheck = Date.now();
    }

    // Check permissions for this specific action
    if (!currentUser.can(msg.action)) {
      ws.send(JSON.stringify({ error: 'Forbidden' }));
      return;
    }

    handleMessage(ws, currentUser, msg);
  });
});

Vulnerability #3: No Input Validation on Messages

❌ Dangerous: Trusting Client Messages

ws.on('message', (data) => {
  const msg = JSON.parse(data);

  switch (msg.action) {
    case 'updateProfile':
      // Directly using client-supplied userId — IDOR!
      db.users.update(msg.userId, { name: msg.name });
      break;
    case 'sendMessage':
      // No sanitization — stored XSS when displayed!
      db.messages.insert({
        text: msg.text,
        room: msg.room,
      });
      break;
  }
});

✅ Fix: Validate and Sanitize Everything

import { z } from 'zod';
import DOMPurify from 'isomorphic-dompurify';

const messageSchemas = {
  updateProfile: z.object({
    action: z.literal('updateProfile'),
    name: z.string().min(1).max(100).regex(/^[a-zA-Z\s]+$/),
  }),
  sendMessage: z.object({
    action: z.literal('sendMessage'),
    text: z.string().min(1).max(5000),
    room: z.string().uuid(),
  }),
};

ws.on('message', (data) => {
  let msg;
  try {
    msg = JSON.parse(data);
  } catch {
    ws.send(JSON.stringify({ error: 'Invalid JSON' }));
    return;
  }

  const schema = messageSchemas[msg.action];
  if (!schema) {
    ws.send(JSON.stringify({ error: 'Unknown action' }));
    return;
  }

  const result = schema.safeParse(msg);
  if (!result.success) {
    ws.send(JSON.stringify({ error: result.error.message }));
    return;
  }

  // Use server-side userId, sanitize text
  if (msg.action === 'sendMessage') {
    msg.text = DOMPurify.sanitize(msg.text);
  }

  handleMessage(ws, ws.userId, result.data); // Use ws.userId, not msg.userId
});

Vulnerability #4: No Rate Limiting

WebSockets maintain a persistent connection, so an attacker can flood messages without opening new connections.

✅ Fix: Message Rate Limiting

const rateLimiter = new Map();

ws.on('message', (data) => {
  const now = Date.now();
  const userLimits = rateLimiter.get(ws.userId) || { count: 0, windowStart: now };

  if (now - userLimits.windowStart > 60000) {
    // Reset window
    userLimits.count = 0;
    userLimits.windowStart = now;
  }

  userLimits.count++;
  rateLimiter.set(ws.userId, userLimits);

  if (userLimits.count > 100) {  // Max 100 messages per minute
    ws.send(JSON.stringify({ error: 'Rate limit exceeded' }));
    return;
  }

  handleMessage(ws, data);
});

Vulnerability #5: Leaking Data via Broadcast

❌ Broadcasting Sensitive Data to All Clients

// When an order is updated, broadcast to all connected clients
function notifyOrderUpdate(order) {
  wss.clients.forEach((client) => {
    // Every connected user sees every order!
    client.send(JSON.stringify({
      type: 'orderUpdate',
      order: order,  // Includes other users' data
    }));
  });
}

✅ Fix: Room-Based + Permission-Filtered Broadcasting

function notifyOrderUpdate(order) {
  wss.clients.forEach((client) => {
    // Only send to the order owner or admin
    if (client.userId === order.userId || client.role === 'admin') {
      // Filter sensitive fields based on role
      const filtered = client.role === 'admin'
        ? order
        : { id: order.id, status: order.status, total: order.total };

      client.send(JSON.stringify({
        type: 'orderUpdate',
        order: filtered,
      }));
    }
  });
}

Vulnerability #6: No TLS (wss://)

// ❌ Unencrypted — anyone on the network can read/modify messages
const ws = new WebSocket('ws://api.example.com/ws');

// ✅ Always use TLS
const ws = new WebSocket('wss://api.example.com/ws');

WebSocket Security Checklist

ControlPriority
Origin validation (CSWSH prevention)Critical
Ticket-based auth (not cookie-based)Critical
Per-message input validation (Zod/Joi)Critical
Periodic session revalidationHigh
Message rate limitingHigh
Room-based authorization for broadcastsHigh
TLS (wss://) everywhereCritical
Message size limitsMedium
Connection timeout/heartbeatMedium

Need a WebSocket Security Review?

We audit WebSocket implementations, Socket.IO servers, and real-time APIs. Request a free sample review →


Published by the SecureCodeReviews.com team — securing real-time applications for production.

Advertisement