Privacy Tools Guide

WebSocket connections stay open and bypass the stateless request-response model that most HTTP security controls assume. An improperly secured WebSocket endpoint can be hijacked, used for cross-site WebSocket hijacking (CSWSH), or abused to exfiltrate data. This guide covers TLS, authentication, origin checks, and rate limiting for production WebSocket deployments.

The Risks

Unencrypted WebSockets (ws://): All messages are transmitted in plaintext, visible to anyone on the path between client and server.

Cross-Site WebSocket Hijacking: A malicious page can open a WebSocket to your server using the victim’s cookies, since browsers send credentials automatically on WebSocket upgrade requests.

Missing rate limits: WebSocket connections are cheap to open and can be used to exhaust server resources or brute-force application protocols.

Step 1: Always Use WSS (WebSocket Secure)

wss:// is WebSocket over TLS — equivalent to HTTPS. Never use ws:// in production.

In Nginx, configure the WebSocket proxy and TLS termination:

# /etc/nginx/sites-available/myapp

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    # WebSocket proxy
    location /ws {
        proxy_pass http://127.0.0.1:8765;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeouts
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

    # Redirect HTTP to HTTPS
    listen 80;
    return 301 https://$host$request_uri;
}

With Caddy (automatic TLS):

app.example.com {
    handle /ws* {
        reverse_proxy localhost:8765 {
            header_up Upgrade {http.upgrade}
            header_up Connection "upgrade"
        }
    }
}

Step 2: Validate the Origin Header

The WebSocket handshake sends an Origin header. Unlike CORS, the browser does not block the connection if the server does not check it — so you must check it yourself:

# Python websockets library example
import websockets
import asyncio

ALLOWED_ORIGINS = {"https://app.example.com", "https://www.example.com"}

async def handler(websocket, path):
    origin = websocket.request_headers.get("Origin", "")
    if origin not in ALLOWED_ORIGINS:
        await websocket.close(1008, "Invalid origin")
        return
    # Handle the connection...
    await handle_connection(websocket, path)

start_server = websockets.serve(
    handler,
    "127.0.0.1",
    8765,
    origins=list(ALLOWED_ORIGINS)
)

asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()

In Node.js with the ws library:

const WebSocket = require('ws');

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://www.example.com'
]);

const wss = new WebSocket.Server({
  port: 8765,
  verifyClient: (info, callback) => {
    const origin = info.origin;
    if (!ALLOWED_ORIGINS.has(origin)) {
      callback(false, 403, 'Forbidden: invalid origin');
      return;
    }
    callback(true);
  }
});

wss.on('connection', (ws, req) => {
  // Handle authenticated connection
  console.log(`New connection from ${req.socket.remoteAddress}`);
});

Step 3: Authenticate WebSocket Connections

Do not rely on cookies alone — use tokens passed in the URL or as the first message:

Token in URL Query Parameter

// Client-side
const token = await getJWT(); // From your auth system
const ws = new WebSocket(`wss://app.example.com/ws?token=${token}`);

// Server-side (Node.js)
const url = require('url');
const jwt = require('jsonwebtoken');

wss.on('connection', (ws, req) => {
  const params = new url.URLSearchParams(url.parse(req.url).query);
  const token = params.get('token');

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ws.user = decoded;
  } catch (err) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  // Proceed with authenticated connection
});

Note: URL parameters appear in server logs. If log privacy matters, use the first-message pattern instead.

Token in First Message

// Server-side: require authentication as first message
wss.on('connection', (ws) => {
  ws.authenticated = false;
  ws.authTimeout = setTimeout(() => {
    if (!ws.authenticated) {
      ws.close(4001, 'Authentication timeout');
    }
  }, 5000); // 5 second window to authenticate

  ws.on('message', async (data) => {
    if (!ws.authenticated) {
      try {
        const { type, token } = JSON.parse(data);
        if (type !== 'auth') {
          ws.close(4001, 'Expected auth message');
          return;
        }
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        ws.user = decoded;
        ws.authenticated = true;
        clearTimeout(ws.authTimeout);
        ws.send(JSON.stringify({ type: 'auth_ok' }));
      } catch {
        ws.close(4001, 'Invalid token');
      }
      return;
    }
    // Handle regular messages after auth
    handleMessage(ws, data);
  });
});

Step 4: Rate Limit Connections

// Track connections per IP
const connectionCounts = new Map();
const MAX_CONNECTIONS_PER_IP = 10;
const RATE_WINDOW_MS = 60000; // 1 minute

wss = new WebSocket.Server({
  port: 8765,
  verifyClient: (info, callback) => {
    const ip = info.req.socket.remoteAddress;
    const now = Date.now();

    if (!connectionCounts.has(ip)) {
      connectionCounts.set(ip, { count: 0, resetAt: now + RATE_WINDOW_MS });
    }

    const record = connectionCounts.get(ip);
    if (now > record.resetAt) {
      record.count = 0;
      record.resetAt = now + RATE_WINDOW_MS;
    }

    record.count++;
    if (record.count > MAX_CONNECTIONS_PER_IP) {
      callback(false, 429, 'Too Many Connections');
      return;
    }

    callback(true);
  }
});

For Nginx-level rate limiting on the upgrade endpoint:

# In nginx.conf http block
limit_req_zone $binary_remote_addr zone=ws_limit:10m rate=5r/s;

# In server block
location /ws {
    limit_req zone=ws_limit burst=10 nodelay;
    # ... proxy config
}

Step 5: Message Validation

Treat every message as untrusted input:

wss.on('connection', (ws) => {
  ws.on('message', (rawData) => {
    // Size limit
    if (rawData.length > 65536) { // 64KB max
      ws.close(1009, 'Message too large');
      return;
    }

    let msg;
    try {
      msg = JSON.parse(rawData);
    } catch {
      ws.close(1007, 'Invalid JSON');
      return;
    }

    // Validate message type
    const VALID_TYPES = new Set(['chat', 'ping', 'subscribe']);
    if (!VALID_TYPES.has(msg.type)) {
      ws.send(JSON.stringify({ error: 'Unknown message type' }));
      return;
    }

    // Process valid message
    handleMessage(ws, msg);
  });
});

Step 6: Secure Headers for the Upgrade Request

Add security headers to the HTTP response that initiates the WebSocket upgrade:

location /ws {
    add_header X-Content-Type-Options "nosniff" always;
    add_header Strict-Transport-Security "max-age=31536000" always;
    # No Content-Security-Policy needed here (it's not an HTML response)

    proxy_pass http://127.0.0.1:8765;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}