Chapter 8 — Security

Security is the chapter most tutorials skip — and the one that bites you in production. A wide-open WebSocket server is a serious attack surface. Here are every threat and its defense.


1. Always Use wss:// in Production

ws:// sends everything as plaintext. Anyone on the same network (café WiFi, shared office) can read every message.

# nginx.conf — TLS termination for WebSocket
server {
  listen 443 ssl;
  server_name api.example.com;

  ssl_certificate     /etc/ssl/certs/example.crt;
  ssl_certificate_key /etc/ssl/private/example.key;

  location /ws {
    proxy_pass         <http://localhost:8080>;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade    $http_upgrade;  # Required for WS!
    proxy_set_header   Connection "upgrade";      # Required for WS!
    proxy_set_header   Host       $host;
    proxy_read_timeout 3600s;  # Keep connection alive
  }
}

Your Node app runs plain ws:// internally. Nginx handles the TLS and proxies to it.


2. JWT Authentication on Every Connection

Browser WebSocket API cannot set custom HTTP headers, so pass the JWT in the query string:

// client.js
const token = localStorage.getItem('jwt');
const ws    = new WebSocket(`wss://api.example.com/ws?token=${token}`);
// server.js
const jwt = require('jsonwebtoken');

wss.on('connection', (ws, req) => {
  const url   = new URL(req.url, '<http://localhost>');
  const token = url.searchParams.get('token');

  if (!token) {
    ws.close(4001, 'Missing authentication token');
    return;
  }

  try {
    const user = jwt.verify(token, process.env.JWT_SECRET);
    ws.user = user; // Attach user info to socket
    console.log('Authenticated:', user.email);
  } catch (err) {
    // Token invalid or expired
    ws.close(4001, 'Invalid or expired token');
    return;
  }

  ws.on('message', (data) => {
    // ws.user is always set — safe to use
    console.log(`Message from ${ws.user.email}:`, data.toString());
  });
});

3. Origin Checking — Prevent CSRF

Without this, any website can open a WebSocket to your server using the visitor's cookies:

// server.js
const ALLOWED_ORIGINS = [
  '<https://myapp.com>',
  '<https://www.myapp.com>',
  '<http://localhost:5173>', // Vite dev server
  '<http://localhost:3000>', // CRA dev server
];

const wss = new WebSocket.Server({
  port: 8080,
  verifyClient: ({ origin, req }, callback) => {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) {
      callback(true); // ✅ Accept
    } else {
      console.warn(`Blocked connection from origin: ${origin}`);
      callback(false, 403, 'Forbidden');
    }
  },
});

4. Rate Limiting — Prevent Message Flooding

// server.js
const MAX_MESSAGES_PER_MINUTE = 100;

wss.on('connection', (ws) => {
  ws.messageCount  = 0;
  ws.rateLimitReset = Date.now() + 60_000;

  ws.on('message', (raw) => {
    const now = Date.now();

    // Reset counter every minute
    if (now > ws.rateLimitReset) {
      ws.messageCount  = 0;
      ws.rateLimitReset = now + 60_000;
    }

    ws.messageCount++;

    if (ws.messageCount > MAX_MESSAGES_PER_MINUTE) {
      ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
      ws.close(4029, 'Too many messages');
      return;
    }

    // Process message...
  });
});