The problem with WebSocket auth

HTTP requests can carry an Authorization: Bearer <token> header. WebSocket connections start as HTTP upgrade requests, so you might expect the same to work. The limitation: the browser’s WebSocket API doesn’t allow setting custom headers.

// This is NOT possible in browser WebSockets
const ws = new WebSocket('wss://api.example.com', {
  headers: { Authorization: 'Bearer my-token' } // doesn't work
});

This forces different authentication patterns for WebSocket connections.

Option 1: token in the URL query string

The simplest approach — include the token in the URL:

const token = getAuthToken();
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);

On the server, read it from the upgrade request URL:

const { WebSocketServer } = require('ws');
const url = require('url');
const jwt = require('jsonwebtoken');

const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (request, socket, head) => {
  const { query } = url.parse(request.url, true);
  const token = query.token;

  try {
    const user = jwt.verify(token, process.env.JWT_SECRET);
    // Attach user to request for later access
    request.user = user;
    wss.handleUpgrade(request, socket, head, ws => {
      wss.emit('connection', ws, request);
    });
  } catch (err) {
    socket.destroy(); // reject the connection
  }
});

Drawback: tokens in URLs appear in server logs, browser history, and web server access logs. This is a real security concern for long-lived tokens. Mitigate with short-lived tokens (30-60 seconds) generated specifically for the WebSocket connection.

Option 2: first-message authentication

Establish the connection unauthenticated, then require the client to send credentials as the first message. Reject the connection if the first message isn’t valid authentication.

// Client
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
};
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'auth_ok') {
    // Connection is now authenticated, proceed
    setupMessageHandlers();
  } else if (msg.type === 'auth_fail') {
    ws.close();
  }
};
// Server
wss.on('connection', (ws) => {
  let authenticated = false;
  let authTimeout;

  // Give the client 5 seconds to authenticate
  authTimeout = setTimeout(() => {
    if (!authenticated) {
      ws.close(4001, 'Authentication timeout');
    }
  }, 5000);

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

    if (!authenticated) {
      if (message.type === 'auth') {
        try {
          const user = jwt.verify(message.token, process.env.JWT_SECRET);
          authenticated = true;
          clearTimeout(authTimeout);
          ws.user = user;
          ws.send(JSON.stringify({ type: 'auth_ok', userId: user.id }));
        } catch {
          ws.send(JSON.stringify({ type: 'auth_fail' }));
          ws.close(4001, 'Authentication failed');
        }
      }
      return; // ignore all other messages until authenticated
    }

    handleMessage(ws, message);
  });
});

Advantage: the token never appears in logs. Drawback: slightly more complex, and there’s a brief window where the connection exists but isn’t authenticated.

Option 3: cookies

Cookies are sent automatically with the WebSocket upgrade request — the browser includes them just as it would with a regular HTTP request.

// Client: no extra work needed, cookie is sent automatically
const ws = new WebSocket('wss://api.example.com/ws');
// Server: read the cookie from the upgrade request
server.on('upgrade', (request, socket, head) => {
  const cookies = parseCookies(request.headers.cookie);
  const sessionToken = cookies['session'];
  // validate and proceed
});

Advantage: no client-side token handling, works with session-based auth. Drawback: requires cookies to be set on the same domain, HttpOnly cookies are readable by the server but the client can’t read them to include in URL params — this is actually a security advantage.

For SPAs with JWT authentication: use short-lived tokens in query params, generated by a dedicated endpoint that issues a one-time-use WebSocket connection token. This keeps long-lived tokens out of logs.

For session-based applications: cookies are the cleanest solution.

For React Native (no browser, full control over headers): send the Authorization header directly, since the WebSocket implementation in React Native is not restricted like the browser’s.