Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 087 / 100

Cross-site WebSocket hijacking: best practices explained

Cookies + WebSockets + no origin check = trouble.

Expona Admin9 min read

Cross-site WebSocket hijacking: best practices explained

WebSockets feel like a safe, persistent connection between your browser and server—but they inherit the same security model as HTTP, including cookie handling. That trust becomes a vulnerability when an attacker crafts a malicious webpage that initiates a WebSocket connection to your API on behalf of a logged-in user. We'll walk through why cross-site WebSocket hijacking happens, how browsers send cookies on WebSocket handshakes, and the practical defenses that turn this into a non-issue in production.

How browsers send cookies on WebSocket handshakes

When your browser opens a WebSocket connection, it automatically includes cookies associated with the target domain—just as it does for HTTP requests. This behavior is by design: the WebSocket upgrade handshake is still an HTTP request under the hood, and the browser's cookie jar applies the same origin policy rules.

Here's what happens step by step:

  1. User logs into your SaaS application at app.example.com; the server sets a sessionId cookie with SameSite=None (or older, less restrictive settings).
  2. User browses to a malicious site, attacker.com, while still logged in.
  3. The attacker's page contains JavaScript that opens a WebSocket to wss://api.example.com/ws.
  4. The browser automatically includes the sessionId cookie in the WebSocket upgrade request headers.
  5. Your WebSocket handler accepts the connection and treats the attacker's session as authenticated.
  6. The attacker's page now has a live socket connection and can send or receive sensitive data on behalf of the real user.

The key insight: cookies are sent automatically by the browser, regardless of which site initiated the connection request. This is why cross-site WebSocket hijacking is possible and why validation at the server is essential.

Validate the Origin header

The first and most direct defense is to check the Origin header on every WebSocket upgrade request. This header tells your server where the connection request originated, and rejecting mismatched origins stops most attacks cold.

When a browser initiates a WebSocket connection, it sets the Origin header to the scheme and host of the page making the request. If attacker.com tries to open a socket to your API, the Origin header will say http://attacker.com, not your application's domain.

In a NestJS application using the @nestjs/websockets package with Socket.IO:

typescript
import { WebSocketGateway, OnGatewayConnection, ConnectedSocket } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { Injectable } from '@nestjs/common';

@WebSocketGateway({
  cors: {
    origin: 'https://app.example.com',
    credentials: true,
  },
})
@Injectable()
export class NotificationGateway implements OnGatewayConnection {
  handleConnection(@ConnectedSocket() client: Socket) {
    const origin = client.handshake.headers.origin;
    const allowedOrigin = 'https://app.example.com';

    if (origin !== allowedOrigin) {
      console.warn(`Rejected connection from origin: ${origin}`);
      client.disconnect(true);
      return;
    }

    console.log(`Client connected from origin: ${origin}`);
  }
}

For a raw Node.js WebSocket server using the ws library:

typescript
import WebSocket, { WebSocketServer } from 'ws';
import http from 'http';

const server = http.createServer();
const wss = new WebSocketServer({ server });

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

wss.on('connection', (ws, req) => {
  const origin = req.headers.origin;

  if (!ALLOWED_ORIGINS.includes(origin)) {
    console.warn(`Rejected connection from origin: ${origin}`);
    ws.close(1008, 'Origin not allowed');
    return;
  }

  console.log(`Client connected from origin: ${origin}`);

  ws.on('message', (data) => {
    console.log(`Received: ${data}`);
  });
});

server.listen(8080);

This approach is simple and effective. However, it requires you to maintain a list of allowed origins, which becomes cumbersome if your frontend is deployed across multiple subdomains or environments. It's a solid foundation but shouldn't be your only line of defense.

Use tokens rather than cookies alone

Cookies are automatically sent by browsers, and that automatic behavior is both a feature and a bug. Attackers exploit the automatic inclusion; defenders should rely on explicit, deliberate authentication mechanisms instead.

Replace or supplement cookie-based session tracking with explicit tokens passed by the client. The client must include the token in the WebSocket handshake, either as a query parameter or as part of the HTTP headers during the upgrade. Since the token is not automatically included by the browser, an attacker cannot hijack the connection without first stealing or intercepting the token.

Here's how to pass a token via query parameters:

typescript
// Client-side (React or vanilla JS)
const token = localStorage.getItem('authToken');
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);

ws.onopen = () => {
  console.log('Connected with explicit token');
};

On the server side, extract and validate the token:

typescript
import WebSocket, { WebSocketServer } from 'ws';
import http from 'http';
import jwt from 'jsonwebtoken';

const server = http.createServer();
const wss = new WebSocketServer({ server });
const JWT_SECRET = process.env.JWT_SECRET;

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

  if (!token) {
    console.warn('Connection attempt without token');
    ws.close(1008, 'Token required');
    return;
  }

  let decoded;
  try {
    decoded = jwt.verify(token, JWT_SECRET);
  } catch (err) {
    console.warn(`Invalid token: ${err.message}`);
    ws.close(1008, 'Invalid token');
    return;
  }

  // Token is valid; attach user info to the socket
  (ws as any).userId = decoded.sub;
  console.log(`User ${decoded.sub} connected via explicit token`);

  ws.on('message', (data) => {
    console.log(`User ${(ws as any).userId} sent: ${data}`);
  });
});

server.listen(8080);

Tokens can also be passed in custom headers during the WebSocket upgrade, though this requires explicit configuration on the client side and is less portable across certain proxies. Query parameters are more widely supported and easier to debug.

The advantage of the token approach: even if an attacker tricks a user into visiting a malicious site, the attacker cannot extract the token from the user's browser storage without additional XSS vulnerabilities. The token is not automatically sent by the browser the way a cookie is.

Apply CSRF thinking to sockets

Cross-Site Request Forgery (CSRF) protection has been battle-tested for decades in the HTTP world. The same principles apply to WebSockets, albeit with slightly different mechanics because WebSockets maintain state and don't follow the request-response pattern.

Traditional CSRF tokens work like this: the server generates a random, unpredictable token and sends it to the client on a page load. When the client submits a form, it includes that token. The server verifies that the token in the request matches the one it issued. An attacker cannot forge the token because it's unpredictable and specific to the user's session.

For WebSockets, adapt the pattern as follows:

  1. When the user loads your frontend application, fetch a CSRF token from an HTTP endpoint.
  2. Store the token in memory (not localStorage, to reduce XSS exposure).
  3. Pass the token when initiating the WebSocket connection.
  4. The server validates the token before accepting the connection.

Example flow:

typescript
// Client-side: fetch CSRF token during app initialization
async function initializeConnection() {
  const csrfRes = await fetch('https://api.example.com/csrf-token', {
    credentials: 'include', // Send cookies to fetch the token
  });
  const { token } = await csrfRes.json();

  // Connect to WebSocket with the token
  const ws = new WebSocket(`wss://api.example.com/ws?csrfToken=${token}`);
  return ws;
}

// Server-side: issue CSRF token on HTTP endpoint
app.get('/csrf-token', (req, res) => {
  const sessionId = req.cookies.sessionId;
  const csrfToken = generateRandomToken();
  
  // Store association: sessionId -> csrfToken (in Redis or in-memory cache)
  sessionStore.set(sessionId, { csrfToken });

  res.json({ token: csrfToken });
});

// Server-side: validate CSRF token on WebSocket upgrade
wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'http://localhost');
  const csrfToken = url.searchParams.get('csrfToken');
  const sessionId = req.cookies.sessionId;

  if (!csrfToken || !sessionId) {
    ws.close(1008, 'CSRF token or session required');
    return;
  }

  const storedToken = sessionStore.get(sessionId)?.csrfToken;
  if (storedToken !== csrfToken) {
    console.warn(`CSRF token mismatch for session ${sessionId}`);
    ws.close(1008, 'CSRF validation failed');
    return;
  }

  // Token is valid; proceed
  (ws as any).sessionId = sessionId;
  ws.on('message', (data) => {
    console.log(`Session ${sessionId} sent: ${data}`);
  });
});

This approach combines the best of both worlds: cookies (which are convenient for session management) and explicit tokens (which prevent automatic hijacking). An attacker who tricks a user into visiting a malicious page cannot obtain the CSRF token because it's fetched from an HTTP endpoint that requires the user's active participation or credentials.

Common mistakes

  • Accepting all origins in CORS. Setting cors: { origin: '*' } defeats origin validation entirely. Always whitelist specific, trusted domains.
  • Trusting the Referer header alone. The Referer header can be spoofed or omitted by proxies. Always validate Origin.
  • Storing tokens in localStorage without XSS protection. If your application has XSS vulnerabilities, tokens in localStorage are accessible to attackers. Consider sessionStorage or in-memory storage, and always sanitize user input.
  • Forgetting to validate tokens on message handlers. Validating the token only on connection is not enough. Malicious users can modify client-side code and send messages as if they're authenticated. Re-verify user identity on sensitive operations.
  • Ignoring SameSite cookie attributes. Set SameSite=Strict or at least SameSite=Lax on session cookies to prevent automatic inclusion in cross-site requests. This is a defense in depth measure.

Key takeaways

  • Browsers automatically include cookies on WebSocket handshakes, making cross-site hijacking possible if you don't validate the origin and identity of the connecting client.
  • Always validate the Origin header on incoming WebSocket connections and reject mismatched origins.
  • Use explicit tokens (JWT, CSRF tokens) passed by the client rather than relying on cookies alone, because tokens are not automatically sent and must be deliberately included by the client.
  • Apply time-tested CSRF principles: issue unpredictable tokens, require the client to pass them on connection, and validate server-side.
  • Combine multiple defenses (origin validation, token validation, SameSite cookies, input sanitization) so that the failure of one measure does not expose the system.
  • Re-validate user identity and permissions on sensitive WebSocket message handlers, not just on connection.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles