Authentication for WebSockets

Getting a token into the handshake without custom headers, validating it server-side, and re-authenticating long-lived connections.

Why WebSocket auth doesn't work like REST auth

An ordinary fetch() call attaches an Authorization: Bearer <token> header on every single request, and the server checks it fresh every time. A WebSocket connection only has one handshake — after that, it's just frames flowing over an already-open connection, not a series of discrete requests each carrying their own headers. Two consequences follow directly from this:

  • Authentication has to happen once, at connect time (or be re-established explicitly later — see below), not per message.
  • The browser's own WebSocket constructor gives you no way to set custom headers at all. Unlike fetch(), there's no headers option — new WebSocket(url, protocols) only accepts a URL and an optional list of subprotocols.

That second point is the one that catches people off guard: the natural instinct, "just send the token as an Authorization header like always," simply isn't available for a browser-originated WebSocket connection.

Getting a token into the handshake

Since a custom header isn't an option for browser clients, there are three practical alternatives:

1. Token in the URL's query string. Simple and universally supported, at the cost of the token potentially ending up in server access logs, browser history, or intermediate proxy logs:

Javascript
const token = await getAccessToken();
const socket = new WebSocket(`wss://example.com/chat?token=${encodeURIComponent(token)}`);

2. Rely on the browser's automatic cookie handling. If the WebSocket endpoint is same-origin (or the cookie is configured with SameSite settings that permit it), the browser automatically attaches the session cookie to the handshake's underlying HTTP request, exactly as it would for any other request to that origin — no explicit token handling needed in the client code at all:

Javascript
// No token passed explicitly — the browser attaches the existing session cookie
const socket = new WebSocket('wss://example.com/chat');

3. Connect anonymously, then authenticate with the first message. The client opens the socket with no credentials, and the server holds the connection in an unauthenticated state until a valid auth message arrives within a grace period:

Javascript
const socket = new WebSocket('wss://example.com/chat');

socket.onopen = () => {
  socket.send(JSON.stringify({ type: 'auth', token: accessToken }));
};

Validating the handshake server-side

The most robust pattern rejects an unauthenticated connection before the WebSocket upgrade even completes, rather than accepting every socket and sorting out identity afterward. Using ws in Node.js, this means hooking the HTTP server's upgrade event directly instead of letting WebSocketServer auto-accept everything:

Javascript
import { WebSocketServer } from 'ws';
import { parse } from 'url';

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

server.on('upgrade', (request, socket, head) => {
  const { query } = parse(request.url, true);
  const user = verifyToken(query.token); // returns null/throws if invalid or expired

  if (!user) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  wss.handleUpgrade(request, socket, head, (ws) => {
    ws.user = user; // attach identity to the connection for use in every later handler
    wss.emit('connection', ws, request);
  });
});

Rejecting at the HTTP-upgrade stage means an invalid token never even gets a live WebSocket connection — the client just gets a 401 and the underlying TCP connection is closed immediately.

Re-authenticating a long-lived connection

A WebSocket connection can stay open for hours, but a typical access token (a short-lived JWT, say 15 minutes) doesn't. Nothing about the WebSocket protocol re-checks authentication automatically as time passes — a connection that was valid at minute 0 stays open at minute 20 even if the token it was authenticated with has since expired, unless the server explicitly does something about it.

Two practical approaches:

Server-driven expiry. The server tracks each connection's token expiry and proactively closes the connection (with a specific close code) once it's reached, prompting the client to reconnect with a freshly issued token:

Javascript
function scheduleExpiry(ws, expiresAt) {
  const msRemaining = expiresAt - Date.now();
  ws.expiryTimer = setTimeout(() => {
    ws.close(4001, 'Token expired — reconnect with a fresh token');
  }, msRemaining);
}

Client-driven re-auth. The client refreshes its access token in the background (using a longer-lived refresh token, exactly as it would for REST calls) and sends the new token over the existing socket before the old one expires, letting the server swap the connection's associated identity in place with no reconnect at all:

Javascript
// Client: refresh proactively, before expiry, and push the new token over the live socket
socket.send(JSON.stringify({ type: 'reauth', token: newAccessToken }));
Javascript
// Server: verify and update the existing connection's identity
socket.on('message', (raw) => {
  const message = JSON.parse(raw.toString());
  if (message.type === 'reauth') {
    const user = verifyToken(message.token);
    if (user) {
      socket.user = user;
      clearTimeout(socket.expiryTimer);
      scheduleExpiry(socket, user.tokenExpiresAt);
    } else {
      socket.close(4001, 'Reauth failed');
    }
  }
});

Common mistakes

  • Assuming new WebSocket(url, protocols) can set an Authorization header the way fetch() does — the browser API has no headers option at all.
  • Putting a long-lived token in the URL query string, where it can persist indefinitely in server access logs and browser history — prefer short-lived tokens, or cookie-based auth where the deployment allows it.
  • Authenticating only once at connect time and never re-validating — a revoked or expired token has no effect on an already-open connection unless the server explicitly checks for it later.
  • Accepting the WebSocket upgrade for every connection unconditionally and only checking identity via the first message, without actually enforcing a grace period and disconnecting sockets that never send valid credentials in time.