Authentication and Authorization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Authenticating a WebSocket is different from authenticating an HTTP request for three reasons. The browser WebSocket API cannot set request headers, so the usual Authorization: Bearer header is not available. The handshake happens once and the connection then lives for minutes or hours, so a credential that was valid at the start can expire, be revoked or change meaning while frames are still flowing. And everything after the handshake is an application protocol, so the server has to decide for every room and every message what the connected user may do, because no URL or HTTP verb is left to attach a rule to. This page answers four questions: how the identity gets onto the connection, what happens when the credential ends, how each action is authorized, and how each stack and protocol (Socket.IO, SignalR, Pusher and Reverb, XMPP) answers the same questions.

The examples were run with ws 8.22.0, jose 6.2.13, node-redis 6.3.0 (redis on npm), Node.js 24 and Redis 8.8.3 (the redis:8.8.3-alpine image), plus socket.io and socket.io-client 4.8.4, pusher 5.3.4 and headless Chrome. The product documentation was checked on 2026-10-24; ASP.NET Core 10 and @microsoft/signalr 10.0.11 are the SignalR baseline. The wire protocol is Chatterbox v1, defined on Message Design and Serialization; the handshake itself is on The WebSocket Protocol and the browser API on The Browser WebSocket API. The attacks these defences answer are collected on Security; the OAuth 2.0 and OpenID Connect material that produces the tokens is in the OAuth Reference section.

Identity, permission and lifetime

  • Authentication proves who the connection belongs to. In Chatterbox it ends with a hello reply that carries the userId. Everything the server later puts in a from field comes from this identity, never from the payload.

  • Authorization decides what that identity may do. It has four levels: may it connect at all, may it join this room, may it send this message, and may it touch this particular object (edit message X). Each level needs its own check.

  • Lifetime is the part HTTP APIs rarely need to think about. A WebSocket authenticated once keeps working after the access token has expired, after the user logged out in another tab and after an administrator removed the user from a room, unless the server arranges otherwise.

The default for all three is deny: an unauthenticated connection can do nothing except send a hello within five seconds, an authenticated one can do nothing in a room it has not been allowed to join, and a connection whose credential has ended is closed with 4401.

What a WebSocket handshake can carry

RFC 6455 does not prescribe an authentication mechanism: the server may use any mechanism available to a generic HTTP server, such as cookies, HTTP authentication or TLS client certificates. What matters is which of them a browser script can control.

Carrier Browser script can set it? Consequence

Cookies

No, but the browser sends the cookies of the target host automatically

Works with an existing login. The same automatic sending is what makes cross-site WebSocket hijacking possible, so the server must check Origin.

Authorization header

No

Usable by Node.js, mobile and command-line clients, not by browsers.

URL query string

Yes (wss://host/ws?ticket=<value>)

Works everywhere. The URL ends up in access logs, proxy logs and monitoring, so only a short-lived, one-time value belongs there.

Sec-WebSocket-Protocol header

Yes, as extra subprotocol names in the constructor

Works everywhere and stays out of the URL. It abuses a header meant for protocol negotiation.

First message after the handshake

Yes

Keeps credentials out of URLs, headers and logs. The connection is open but unauthenticated until the message arrives, so the server must limit what that window allows.

Client certificate (mutual TLS)

Configured on the device or browser, not by script

Strong, but needs certificate management, so it suits devices and service-to-service links rather than public chat.

The OWASP WebSocket Security Cheat Sheet recommends avoiding tokens in the URL query string, because they leak into access logs, and sending the token in the first message over wss:// instead. It also asks for an explicit Origin allow-list, authorization of every action, closing connections when the session expires, and rotating tokens on long-lived connections.

The Chatterbox design

Chatterbox combines three of these carriers so that every client type has a safe path:

  1. The web page is logged in with a cookie session or holds a short-lived access token (see Browser-Based Apps). It calls POST /api/ticket, which accepts either credential, and receives a ticket: a random 192-bit value stored in Redis for 30 seconds, valid once, remembering the user, the role and the expiry of the credential that minted it.

  2. It opens wss://chat.example.test/ws with the chatterbox.v1 subprotocol. The server checks Origin against an allow-list during the handshake, and refuses a request that has an Origin outside the list with HTTP 403.

  3. The first frame is hello carrying the ticket. The server redeems it with an atomic GETDEL, binds the identity to the connection and arms a timer for the credential’s expiry. A missing, replayed or expired ticket gets an unauthorized error and close code 4401.

  4. When the credential expires, or the user logs out anywhere, the server closes the socket with 4401. The client treats that as "authenticate again", not as a failure: it gets a new access token, a new ticket and reconnects at once.

sequenceDiagram participant B as Browser participant A as API (HTTP) participant R as Redis participant W as WebSocket server B->>A: POST /api/ticket with access token or session cookie A->>R: SET ticket EX 30 (user, role, credential expiry) A-->>B: ticket B->>W: GET /ws, Origin, Sec-WebSocket-Protocol chatterbox.v1 W-->>B: 101 Switching Protocols (or 403 for a foreign Origin) B->>W: hello with the ticket W->>R: GETDEL ticket R-->>W: user, role, expiry W-->>B: hello reply with userId Note over W: timer closes the socket at the credential expiry W-->>B: close 4401 token expired B->>A: new access token, then POST /api/ticket B->>W: reconnect and hello with the new ticket

Non-browser clients (a Node.js bot, a mobile app) can use the same flow, or send the Authorization header on the handshake instead of a ticket. A client that sends no Origin header cannot be a cross-site browser, and it is accepted only because it still has to prove who it is with a ticket or a header.

Why not put the access token itself in the first message? It works, and several stacks do it. A ticket is better for three reasons: it can be redeemed only once, so a copy captured from a log or a proxy is worthless seconds later; it lets the WebSocket server avoid knowing how access tokens are issued, because it only talks to Redis; and a long-lived token never travels in a frame that application logging might record. The cost is one extra HTTP request per connection, which is the same request that already exists for reconnection storms (see Connection Lifecycle, Heartbeats and Reconnection), so rate-limit it as you would a login.

A browser that is already logged in to the site needs nothing more than the cookie: it is sent with the WebSocket handshake exactly as with any other request to the same host. Django Channels works this way (AuthMiddlewareStack reads the session cookie and fills scope["user"], see Django authentication), Spring Security hands the authenticated Principal of the upgrade request to the WebSocket session, and ASP.NET Core SignalR with cookie authentication needs no extra code for the same origin.

The risks come from the same automatic sending:

  • Cross-site WebSocket hijacking. A page on evil.example.test can open wss://chat.example.test/ws, the browser attaches the victim’s cookie, and the attacker’s script now reads and writes as the victim. The Same-Origin Policy and CORS do not apply to WebSockets (see What is CORS?). The defence is to compare the Origin header with an allow-list of exact origins during the handshake, as the example server does, and to answer 403 otherwise. A non-browser client can forge any Origin, so this stops browsers, which is the attack, and is not authentication.

  • Cookie attributes. Set HttpOnly (scripts cannot read the session), Secure (HTTPS only) and SameSite=Lax or Strict. A SameSite cookie is not sent when the connecting page is on another site, which is a useful second layer, but "site" means the registrable domain: a page on a sibling subdomain is same-site and still gets the cookie, so keep the Origin check.

  • CSRF on the ticket endpoint. If POST /api/ticket accepts the cookie, a hostile page can make the browser call it. The attacker cannot read the response (CORS), so the ticket is useless to them, but the endpoint should still check Origin and require a JSON content type, as the example does.

  • Logout and idle timeout must reach the open sockets, see the expiry section below.

In headless Chrome the cookie from a login made through the browser’s request context was sent with the WebSocket handshake of a page served from another port of the same host (same site, different origin), and the handshake succeeded because that origin was on the allow-list. A page on 127.0.0.1 against the same server was refused at the handshake and the page saw only a generic error event followed by close code 1006, because the API deliberately tells scripts nothing about a failed handshake.

Tokens in the URL, in a subprotocol or in the first message

Query string. Opening wss://chat.example.test/ws?ticket=<value> is simple and lets the server reject the handshake with HTTP 401 before any frame is exchanged. The URL is logged by reverse proxies, load balancers and application servers, appears in tracing and error reports and may be kept for weeks. That is acceptable for a value that is already useless: one-time, redeemed in milliseconds, expiring in 30 seconds. It is not acceptable for a long-lived access token. SignalR’s JavaScript client sends the access token as the access_token query parameter for the WebSocket and Server-Sent Events transports, and ASP.NET Core documents the logging caveat and the OnMessageReceived hook that reads it back (see SignalR); Chatterbox on SignalR sends a ticket in hello instead.

Subprotocol. The second argument of the constructor takes a list of names. A client can offer ["chatterbox.v1", "ticket.<value>"]; the server reads the second entry, validates it and selects only chatterbox.v1 in its reply. If the server selected the credential entry, it would echo the secret in the response. The characters are limited to the token set of HTTP (a base64url value qualifies), and some environments log request headers, so the benefit over a query string is smaller than it looks. Use it when a proxy blocks long query strings or when URLs are logged more widely than headers, and still use a one-time value. The ws library’s handleProtocols option selects the protocol; the example accepts the handshake only if chatterbox.v1 was offered.

First message. Authenticating inside the protocol, as Chatterbox’s hello does, keeps credentials out of every log that records URLs and headers, works the same for all client types and fits reconnection. The cost is the unauthenticated window between the upgrade and the frame. Close it with:

  • a short deadline for hello (5 seconds in Chatterbox, close 4408), so idle sockets are not held open;

  • a small frame limit (maxPayload: 16384) and the per-connection token bucket from the protocol, applied before authentication;

  • a per-IP cap on concurrent unauthenticated connections and on handshakes per second (see Security);

  • no processing of any frame other than hello before it succeeds: anything else gets unauthorized and close 4401.

Handshake-time authentication (cookie, query string, subprotocol) and first-message authentication can coexist, which the example uses: if the handshake already identified the user, hello needs no token.

A complete server

The server below is the authentication layer of Chatterbox, reduced to the frame types that authentication touches (hello, join, message, edit, ack and error; the rest of the protocol is on uWebSockets.js and the other server pages). It is one file, uses the ws library in noServer mode so that it controls the upgrade, and keeps tickets, sessions and revocation in Redis so that any instance can redeem a ticket minted by any other. Rooms and messages are in memory, which is enough here because the subject is who may do what. The user table, the password check and the shared secret are placeholders: change-me must be replaced, and a real deployment gets its users from the identity provider.

Install with npm install ws@8.22.0 jose@6.2.13 redis in a directory whose package.json declares the package as an ES module (the field type set to module), start Redis (docker run -d -p 6379:6379 redis:8.8.3-alpine) and run node server.mjs. The environment variables PORT, REDIS_URL, ORIGINS, ACCESS_TTL_S and TICKET_TTL_S change the defaults.

// server.mjs -- Chatterbox authentication demo: login, one-time tickets, handshake checks, expiry, revocation.
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import { WebSocketServer } from 'ws';
import { SignJWT, jwtVerify } from 'jose';
import { createClient } from 'redis';

const PORT = Number(process.env.PORT ?? 8080);
const ORIGINS = new Set((process.env.ORIGINS ?? 'https://chat.example.test').split(','));
const ACCESS_TTL_S = Number(process.env.ACCESS_TTL_S ?? 300);   // access token life
const TICKET_TTL_S = Number(process.env.TICKET_TTL_S ?? 30);    // one-time ticket life
const SESSION_TTL_S = 3600;                                      // cookie session life
const HELLO_MS = 5000;
const KEY = new TextEncoder().encode(process.env.JWT_SECRET ?? 'change-me-change-me-change-me-32b'); // HS256 needs 32 bytes
const USERS = {                                                  // stand-in for a user store
  alice: { password: 'change-me', role: 'member' },
  bob: { password: 'change-me', role: 'member' },
  carol: { password: 'change-me', role: 'admin' },
};

const redis = await createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' }).connect();
const sub = redis.duplicate();
await sub.connect();

const b64 = () => randomBytes(24).toString('base64url');
const sid = () => 's_' + [...randomBytes(26)].map((b) => '0123456789ABCDEFGHJKMNPQRSTVWXYZ'[b & 31]).join('');
const now = () => Math.floor(Date.now() / 1000);

// ---------- identity on plain HTTP requests: bearer token first, then session cookie ----------
async function identify(req) {
  const auth = req.headers.authorization ?? '';
  if (auth.startsWith('Bearer ')) {
    try {
      const { payload } = await jwtVerify(auth.slice(7), KEY, { algorithms: ['HS256'], issuer: 'chatterbox', audience: 'chatterbox-api' });
      return { user: payload.sub, role: payload.role, exp: payload.exp };
    } catch { return null; }
  }
  const sessionId = /(?:^|;\s*)cb_sid=([\w-]+)/.exec(req.headers.cookie ?? '')?.[1];
  if (!sessionId) return null;
  const [raw, ttl] = await Promise.all([redis.get(`session:${sessionId}`), redis.ttl(`session:${sessionId}`)]);
  return raw ? { ...JSON.parse(raw), exp: now() + ttl } : null;
}

const originOk = (req) => !req.headers.origin || ORIGINS.has(req.headers.origin);
const json = (res, status, body, headers = {}) => {
  res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
  res.end(JSON.stringify(body));
};
const readJson = async (req) => { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; };

const http = createServer(async (req, res) => {
  try {
    if (req.method !== 'POST') return json(res, 404, {});
    if (!originOk(req)) return json(res, 403, { error: 'origin' });   // cookie-authenticated POSTs need an Origin check too
    if (req.url === '/api/login') {
      const { user, password } = await readJson(req);
      if (!USERS[user] || USERS[user].password !== password) return json(res, 401, { error: 'bad credentials' });
      const id = b64();
      await redis.set(`session:${id}`, JSON.stringify({ user, role: USERS[user].role }), { EX: SESSION_TTL_S });
      const accessToken = await new SignJWT({ role: USERS[user].role })
        .setProtectedHeader({ alg: 'HS256' }).setSubject(user).setIssuer('chatterbox').setAudience('chatterbox-api')
        .setIssuedAt().setExpirationTime(`${ACCESS_TTL_S}s`).sign(KEY);
      return json(res, 200, { accessToken, expiresIn: ACCESS_TTL_S },
        { 'Set-Cookie': `cb_sid=${id}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_S}` });
    }
    if (req.url === '/api/ticket') {
      const who = await identify(req);
      if (!who) return json(res, 401, { error: 'unauthorized' });
      const ticket = b64();
      await redis.set(`ticket:${ticket}`, JSON.stringify(who), { EX: TICKET_TTL_S });  // carries the credential's own expiry
      return json(res, 200, { ticket, expiresIn: TICKET_TTL_S });
    }
    if (req.url === '/api/logout') {
      const who = await identify(req);
      if (!who) return json(res, 401, { error: 'unauthorized' });
      await redis.publish('revoke', who.user);                      // every instance closes this user's sockets
      return json(res, 200, { ok: true });
    }
    return json(res, 404, {});
  } catch (e) { return json(res, 500, { error: 'internal' }); }
});

// ---------- WebSocket: authenticate the handshake when the browser can, else on the first frame ----------
const wss = new WebSocketServer({ noServer: true, maxPayload: 16384, handleProtocols: (p) => (p.has('chatterbox.v1') ? 'chatterbox.v1' : false) });
const deny = (socket, status, text) => { socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); socket.destroy(); };
const redeem = async (t) => { const raw = typeof t === 'string' && t ? await redis.getDel(`ticket:${t}`) : null; return raw ? JSON.parse(raw) : null; };

http.on('upgrade', async (req, socket, head) => {
  socket.on('error', () => {});
  try {
    const url = new URL(req.url, 'http://localhost');
    if (url.pathname !== '/ws') return deny(socket, 404, 'Not Found');
    if (!originOk(req)) return deny(socket, 403, 'Forbidden');       // CSWSH defence: Origin allow-list
    const offered = (req.headers['sec-websocket-protocol'] ?? '').split(',').map((s) => s.trim());
    if (!offered.includes('chatterbox.v1')) return deny(socket, 400, 'Bad Request');
    let who = null;
    const t = url.searchParams.get('ticket') ?? offered.find((p) => p.startsWith('ticket.'))?.slice(7);
    if (t !== undefined) { who = await redeem(t); if (!who) return deny(socket, 401, 'Unauthorized'); }
    else if (req.headers.cookie) who = await identify(req);          // cookie session: no ticket needed
    wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req, who));
  } catch { deny(socket, 500, 'Internal Server Error'); }
});

const byUser = new Map();       // user -> Set<conn>, for revocation
const rooms = new Map();        // room -> { seq, members: Set<conn>, messages: Map<id, envelope> }
const roomOf = (name) => rooms.get(name) ?? rooms.set(name, { seq: 0, members: new Set(), messages: new Map() }).get(name);

function canJoin({ user, role }, room) {                              // per-room authorization
  if (!/^[a-z0-9-]{1,64}$/.test(room)) return false;
  if (room.startsWith('dm-')) return room.slice(3).split('-').includes(user);   // placeholder: real names need a safe delimiter
  if (room === 'staff') return role === 'admin';
  return true;
}

function send(conn, type, ref, room, payload) {
  conn.ws.send(JSON.stringify({ v: 1, type, id: sid(), room, ts: Date.now(), payload: ref ? { ref, ...payload } : payload }));
}
function end(conn, code, reason) {
  conn.ws.close(code, reason);
  setTimeout(() => conn.ws.terminate(), 1000).unref();
}
function fail(conn, ref, code, message, closeCode) {
  send(conn, 'error', null, null, { ref, code, message, retryable: false });
  if (closeCode) end(conn, closeCode, message);
}

function bind(conn, who) {                                           // identity known: remember it and arm the expiry timer
  Object.assign(conn, who);
  (byUser.get(who.user) ?? byUser.set(who.user, new Set()).get(who.user)).add(conn);
  const ms = Math.min(who.exp * 1000 - Date.now(), 2 ** 31 - 1);
  conn.expiry = setTimeout(() => end(conn, 4401, 'token expired'), Math.max(ms, 0));
}

wss.on('connection', (ws, req, who) => {
  const conn = { ws, user: null, helloDone: false, joined: new Set() };
  if (who) bind(conn, who);
  const helloTimer = setTimeout(() => end(conn, 4408, 'hello timeout'), HELLO_MS);
  ws.on('close', () => {
    clearTimeout(helloTimer); clearTimeout(conn.expiry);
    byUser.get(conn.user)?.delete(conn);
    for (const r of conn.joined) rooms.get(r)?.members.delete(conn);
  });
  ws.on('error', () => {});
  ws.on('message', async (data, isBinary) => {
    if (isBinary) return end(conn, 4400, 'text only');
    let env;
    try { env = JSON.parse(data.toString()); } catch { return end(conn, 4400, 'bad json'); }
    if (conn.user && conn.exp * 1000 <= Date.now()) return end(conn, 4401, 'token expired');   // belt and braces
    if (env.type === 'hello') return hello(conn, env, helloTimer);
    if (!conn.helloDone) return fail(conn, env.id, 'unauthorized', 'say hello first', 4401);
    handlers[env.type] ? handlers[env.type](conn, env) : fail(conn, env.id, 'bad_request', 'unknown type');
  });
});

async function hello(conn, env, helloTimer) {
  if (conn.helloDone) return fail(conn, env.id, 'conflict', 'already authenticated');
  if (!conn.user) {                                                  // not authenticated by the handshake: redeem the ticket
    const who = await redeem(env.payload?.token);
    if (!who) return fail(conn, env.id, 'unauthorized', 'invalid or used ticket', 4401);
    bind(conn, who);
  }
  clearTimeout(helloTimer);
  conn.helloDone = true;
  send(conn, 'hello', env.id, null, { userId: conn.user, name: conn.user, sessionId: b64(), heartbeatMs: 20000, maxFrameBytes: 16384, resumed: false, rooms: [] });
}

const ack = (conn, env, seq = null, result = {}) => send(conn, 'ack', env.id, env.room ?? null, { ok: true, seq, dup: false, result });
const handlers = {
  join(conn, env) {
    if (!canJoin(conn, env.room)) return fail(conn, env.id, 'forbidden', 'not allowed in this room');
    const room = roomOf(env.room);
    room.members.add(conn); conn.joined.add(env.room);
    ack(conn, env, null, { members: [...room.members].map((m) => ({ userId: m.user, name: m.user, status: 'online' })), lastSeq: room.seq });
  },
  message(conn, env) {
    if (!conn.joined.has(env.room)) return fail(conn, env.id, 'forbidden', 'join the room first');
    const room = roomOf(env.room);
    const out = { from: { userId: conn.user, name: conn.user }, text: String(env.payload?.text ?? ''), replyTo: null, attachments: [], seq: ++room.seq, edited: false };
    room.messages.set(env.id, out);
    ack(conn, env, out.seq);
    for (const m of room.members) send(m, 'message', null, env.room, out);
  },
  edit(conn, env) {
    const target = roomOf(env.room).messages.get(env.payload?.target);
    if (!target) return fail(conn, env.id, 'not_found', 'unknown message');
    if (target.from.userId !== conn.user) return fail(conn, env.id, 'forbidden', 'only the author can edit');  // object-level check
    target.text = String(env.payload.text); target.edited = true;
    ack(conn, env);
  },
};

await sub.subscribe('revoke', (user) => { for (const c of byUser.get(user) ?? []) end(c, 4401, 'session revoked'); });
http.listen(PORT, () => console.log(`chatterbox auth demo on :${PORT}`));

The design decisions, in the order the code makes them:

  • The upgrade is handled by hand. The ws documentation discourages verifyClient and recommends authenticating in the HTTP server’s upgrade event, where a refusal is a plain HTTP response (403, 401, 400) and the socket is destroyed before any WebSocket object exists. deny() writes that response.

  • Origin before everything else. A request with an Origin outside the allow-list is refused with 403. A request without Origin is allowed through to the ticket check, because it cannot be a browser.

  • A subprotocol is required and selected explicitly. handleProtocols returns chatterbox.v1 and nothing else, so a ticket.<value> entry offered by the client is read by the upgrade handler but never echoed.

  • Three handshake paths and one hello path. A ?ticket= value or a ticket. subprotocol entry is redeemed during the handshake (failure: HTTP 401); a cookie session is looked up in Redis; otherwise the connection stays anonymous until hello, which redeems payload.token. All of them end in bind().

  • One-time and shared. redeem() is GETDEL, atomic in Redis, so two instances racing for the same ticket cannot both succeed, and the 30-second EX makes unused tickets disappear.

  • The credential’s lifetime is the connection’s lifetime. bind() copies exp from the ticket (which copied it from the access token or the session) and arms a timer that closes with 4401 and the reason token expired. The message handler checks the same deadline on every frame, because timers can be late under load.

  • Authorization is code, not configuration. canJoin() is the room policy, joined is the membership the message handler checks, and edit compares the author. Failures are Chatterbox forbidden errors, which are not retryable.

  • Identity is never read from the payload. The from field of a broadcast comes from conn.user.

  • Revocation fans out. POST /api/logout publishes the user name on a Redis channel; every instance closes that user’s sockets with 4401.

A browser-style client

The client asks for a ticket before every connection attempt, because tickets are one-time, and it distinguishes close codes: 4401 means "authenticate again" and reconnects at once; 4403 and 4426 stop retrying; everything else uses exponential back-off with full jitter. getAccessToken is the application’s hook: it returns a valid access token, refreshing it first when needed (see Access and Refresh Tokens), and the refresh token never touches the WebSocket. It uses only browser APIs (fetch, WebSocket, crypto), so it also runs unchanged in Node.js 24, which is how it was tested.

// client.mjs -- browser-style Chatterbox client: ticket per connection, first-message hello, re-auth on close 4401.
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const newId = () => {                                              // 26 characters: 10 of time, 16 random (ULID layout)
  let t = Date.now(), time = '';
  for (let i = 0; i < 10; i++) { time = CROCKFORD[t % 32] + time; t = Math.floor(t / 32); }
  return time + [...crypto.getRandomValues(new Uint8Array(16))].map((b) => CROCKFORD[b & 31]).join('');
};

export class SignedOut extends Error {}

export function connectChat({ api, url, getAccessToken, onState, onEnvelope = () => {} }) {
  let socket, attempts = 0, closedByUs = false;

  async function fetchTicket() {
    const res = await fetch(`${api}/api/ticket`, { method: 'POST', headers: { Authorization: `Bearer ${await getAccessToken()}` } });
    if (res.status === 401) throw new SignedOut();
    if (!res.ok) throw new Error(`ticket ${res.status}`);
    return (await res.json()).ticket;
  }

  const retryLater = () => {                                       // full jitter, capped
    const ms = Math.random() * Math.min(30000, 500 * 2 ** attempts++);
    onState(`retry in ${Math.round(ms)} ms`);
    setTimeout(open, ms);
  };

  async function open() {
    if (closedByUs) return;
    let ticket;
    try { ticket = await fetchTicket(); }
    catch (e) { return e instanceof SignedOut ? onState('signed-out') : retryLater(); }   // 401: stop, do not hammer

    socket = new WebSocket(url, ['chatterbox.v1']);
    socket.onopen = () => socket.send(JSON.stringify({ v: 1, type: 'hello', id: newId(), room: null, ts: 0, payload: { token: ticket, client: 'web/1.4' } }));
    socket.onmessage = (e) => {
      const env = JSON.parse(e.data);
      if (env.type === 'hello') { attempts = 0; onState(`online as ${env.payload.userId}`); } else onEnvelope(env);
    };
    socket.onclose = (e) => {
      if (closedByUs) return;
      if (e.code === 4401) { onState('credential expired, re-authenticating'); return open(); }   // new token, new ticket, no back-off
      if (e.code === 4403 || e.code === 4426) return onState(`fatal close ${e.code}`);             // retrying cannot help
      retryLater();
    };
  }

  open();
  return { close() { closedByUs = true; socket?.close(1000); } };
}

Against a server whose access tokens live two seconds (the server started with ACCESS_TTL_S=2), a client that re-logs in on each expiry printed this. The first connection closed after about one second because token expiry has second granularity; the later ones lasted the two seconds of the token.

   27 ms  online as alice
 1087 ms  credential expired, re-authenticating
 1092 ms  online as alice
 3090 ms  credential expired, re-authenticating
 3101 ms  online as alice
 5089 ms  credential expired, re-authenticating
 5098 ms  online as alice

Verified behaviour

A script ran the checks below against two instances of the server on different ports sharing one Redis (instance A with 300-second tokens, instance B with 2-second tokens) and printed this. The same four handshake paths were also exercised from headless Chrome with real WebSocket objects: cookie, ticket. subprotocol, ?ticket= query string and first-message ticket all produced hello for the right user, the selected subprotocol was chatterbox.v1 each time, an anonymous page received unauthorized, and a page on a foreign origin received only an error event.

ok   login returns an access token and a session cookie
ok   ticket endpoint refuses an anonymous request
ok   ticket endpoint refuses a tampered token
-- first-message ticket (the Chatterbox default)
ok   ticket minted by one instance is redeemed by another
ok   a replayed ticket gets unauthorized and close 4401
ok   hello without a token gets unauthorized and close 4401
ok   any frame before hello closes with 4401
ok   silence after open closes with 4408 after 5 s
-- handshake checks
ok   a foreign Origin is refused with HTTP 403
ok   a client without Origin reaches hello (it must still present a ticket)
ok   a request without the chatterbox.v1 subprotocol is refused
-- cookie session (same origin, no ticket in hello)
ok   the session cookie authenticates the handshake
ok   a cross-site page with a valid cookie is still refused by the Origin check
ok   the cookie-authenticated ticket endpoint checks Origin as well
-- ticket in the query string and in the subprotocol
ok   query-string ticket authenticates the handshake
ok   an unknown query-string ticket is refused with 401 at the handshake
ok   the server selects chatterbox.v1, never echoing the ticket entry
ok   subprotocol ticket authenticates the handshake
-- authorization
ok   a third user cannot join a DM room
ok   a participant can join the DM room
ok   a member cannot join the admin-only room
ok   an admin can join it
ok   sending to a room you have not joined is forbidden
ok   a member of the room can send and others receive
ok   only the author can edit a message
ok   the author can edit it
-- revocation on logout
ok   logout on instance B closes the user's socket on instance A with 4401
-- expiry of the credential
ok   the server closes the socket with 4401 when the token expires (after 1291 ms)
ok   an expired access token can no longer mint tickets
ok   reconnecting with a fresh token and a new ticket works
all authentication checks passed

Token expiry on long-lived connections

Three things end a credential while the socket is open, and each needs its own mechanism:

Event Mechanism Chatterbox

The token or session reaches its expiry

A timer armed at authentication time, plus a check on each frame

bind() arms the timer; the close is 4401 with reason token expired.

The user logs out, is disabled or has a session revoked

A revocation signal delivered to every node that holds the user’s sockets

Redis publish and subscribe; the node closes the user’s sockets with 4401. Without a backplane, periodic re-validation (the OWASP cheat sheet suggests roughly every 30 minutes) bounds the window.

The user’s permissions change

Re-check on the next action, or close and re-authenticate

canJoin() runs on every join; a role change that should remove existing room memberships needs the same revocation message.

Close and reconnect (the default). It works with every stack, needs no protocol extension and reuses the reconnection path that must exist anyway. Make it cheap: 4401 is not an error, so the client reconnects immediately with a fresh ticket, and resumes the session (see Connection Lifecycle, Heartbeats and Reconnection) so that no messages are lost. The resumed session must be bound to the same user: a sessionId is a bearer secret, and a hello that authenticates as someone else must not be able to resume it.

In-band re-authentication. The alternative is to let the client present a new credential on the open connection. Chatterbox does not define a frame for it (a second hello is a conflict), but other stacks do:

  • SignalR. By default the connection continues after the token expires (long-polling and Server-Sent Events fail on the next request instead). CloseOnAuthenticationExpiration = true in the MapHub options closes the connection when the token expires so that the client reconnects with a new token. The ASP.NET Core documentation for newer releases adds EnableAuthenticationRefresh on the server and withAuthenticationRefresh() and refreshAuthentication() on the JavaScript client, which swap the token on the live connection through a refresh route next to negotiate (proxies must forward it) without changing the user identifier. See SignalR for the version notes.

  • Socket.IO. There is no refresh, because the auth object is read once per connection attempt. The pattern is the one in the next section: disconnect on expiry and let the client connect again, with auth as a function so it fetches a new ticket each time.

  • XMPP. SASL runs once per stream; Stream Management resumption (XEP-0198) keeps the session across a reconnect, and FAST tokens (below) let the client authenticate again without a password.

Avoid a thundering herd. If every access token lives exactly one hour and a thousand users logged in during the same minute, a thousand sockets close within the same second and the ticket endpoint receives a thousand requests at once. Add jitter to the server-side deadline (a random few minutes) or to the client’s reconnect delay, and rate-limit /api/ticket. This is the same reconnection-storm problem described on Scaling WebSockets.

Clock skew. The server compares exp with its own clock; it never trusts a client clock. If tickets and tokens are issued by one service and checked by another, allow a small tolerance in verification (jose has a clockTolerance option) and keep the timer on the WebSocket server.

Socket.IO: middleware and the auth option

Socket.IO already has the pieces: the client sends credentials in the auth option (an object, or a function that is called on each connection attempt), the server reads them in socket.handshake.auth inside io.use(), and next(err) refuses the connection, which the client sees as connect_error with the error’s data. The code below is the Chatterbox ticket check as Socket.IO middleware, with an in-memory map standing in for Redis. It also closes the socket when the ticket’s credential expires; socket.disconnect(true) closes the underlying connection, the client reports the reason io server disconnect, and Socket.IO does not reconnect after that reason on its own, so the client calls connect() itself.

// Server: refuse unknown tickets, close at credential expiry.
io.use((socket, next) => {
  const who = redeem(socket.handshake.auth?.ticket);       // one-time: Redis GETDEL in production
  if (!who) { const err = new Error('unauthorized'); err.data = { code: 'unauthorized' }; return next(err); }
  socket.data.user = who.user;
  socket.data.expiry = setTimeout(() => socket.disconnect(true), who.exp - Date.now());
  next();
});
io.on('connection', (socket) => {
  socket.on('disconnect', () => clearTimeout(socket.data.expiry));
  socket.emit('hello', { userId: socket.data.user });
});

// Client: auth is a function, so every attempt gets a new ticket.
const socket = io(url, { auth: (cb) => cb({ ticket: mint() }) });
socket.on('disconnect', (reason) => { if (reason === 'io server disconnect') socket.connect(); });

With a ticket lifetime of 800 ms the run printed connect_error unauthorized {"code":"unauthorized"} for a bad ticket, then hello alice, disconnect io server disconnect and another hello alice on each of three attempts. Socket.IO has no close codes and no subprotocol, so the Chatterbox 4401 is carried by the error envelope and the disconnect, as the Socket.IO page explains; namespaces are the unit of authorization (one middleware per namespace), and socket.join() on the server is the place to check room permissions.

Authorization at four levels

Level Question Where it is enforced

Connect

May this identity use the service at all?

The handshake (origin, ticket) and hello.

Room

May it join staff, or the DM dm-alice-carol?

The join handler: a policy function from user, role and room name to a decision.

Message

Is it a member of the room it sends to, within its rate limit, with a valid payload?

Each handler, before any work. Membership is state the server keeps, never a claim in the frame.

Object

May it edit message X, remove reaction Y, read this history page?

The handler that loads the object: compare the author or owner with the connection’s identity.

Rules that the example follows and that apply to every stack:

  • Deny by default. Unknown frame types are errors, a frame before hello closes the connection, and a room is joinable only if a rule says so.

  • The identity comes from the connection. Never accept userId, from or role in a payload. The server stamps them.

  • Check where the data is loaded. The edit handler fetches the message and then compares the author. A check done before the lookup, on a client-supplied id, is a time-of-check mistake waiting to happen.

  • Room names are not secrets. A DM room called dm-alice-carol is protected by the rule in canJoin(), not by the fact that nobody knows the name. The simple rule in the example needs a safe delimiter between user names in real life.

  • Re-check on resume. A resumed session replays messages for rooms the user is still allowed in; a membership that was removed during the outage must not come back.

  • The error is generic, the log is specific. The client sees forbidden; the server logs the user, room and rule.

  • Authorize the subscription, not only the publication. On Spring’s STOMP support, for example, a client must not be allowed to subscribe to another user’s queue, and must not send to broker destinations directly.

The stack-specific tools:

  • Spring. The upgrade request goes through the normal SecurityFilterChain, and its Principal becomes the WebSocket session’s. For STOMP, @EnableWebSocketSecurity with an AuthorizationManager of MessageMatcherDelegatingAuthorizationManager authorizes each inbound message by destination and type, and a CSRF token is required in the CONNECT frame by default. Spring WebFlux’s raw WebSocketHandler has only the handshake, so authorize each frame in your own code, as the Spring WebFlux WebSockets page does. See also Spring Security.

  • ASP.NET Core SignalR. [Authorize] with policies on a hub or a method, evaluated when the connection is made and on each invocation; Context.User is the identity. See Authorization and Authentication and Identity.

  • Django Channels. AuthMiddlewareStack and scope["user"], checked in connect() and in each handler; the Channels documentation suggests that a long-running consumer re-check the user with get_user(scope), because logging out elsewhere does not close it. See Django authentication and Django Channels.

  • Laravel. Channel authorization callbacks, described next. See Sanctum and Passport.

  • NestJS. Guards run on gateway messages as on controllers; see NestJS WebSockets and NestJS authentication.

  • Node.js. Middleware is yours to write; see Authentication and Sessions.

Pusher and Reverb channel authorization

The Pusher protocol, which Laravel Reverb speaks (see Laravel Reverb), separates the long-lived connection from the permission to hear a channel. The socket itself is anonymous: it connects with the public app key and receives a socket_id. For a private- or presence- channel the client first asks the application server (over ordinary HTTP, with the application’s own authentication, for example a Sanctum bearer token sent to /api/broadcasting/auth) whether it may subscribe. If so, the server returns a signature, and the client sends it with the pusher:subscribe frame. The WebSocket server verifies the signature with the app secret and does no database lookup.

  • Private channel: the string to sign is socket_id:channel_name.

  • Presence channel: socket_id:channel_name:channel_data, where channel_data is the JSON-encoded user object, and the response carries it as a JSON string as well.

  • The signature is the hex HMAC-SHA256 of that string keyed with the app secret, returned as app_key:signature.

This is the whole algorithm in Node.js, checked against the pusher 5.3.4 library’s authorizeChannel() for a private and a presence channel (both returned identical values):

import { createHmac } from 'node:crypto';

const key = 'example-key', secret = 'example-secret';     // placeholders; the secret never leaves the servers
const sign = (stringToSign) => `${key}:${createHmac('sha256', secret).update(stringToSign).digest('hex')}`;

const socketId = '1234.5678', channel = 'presence-chat.general';
const channelData = JSON.stringify({ user_id: '1', user_info: { name: 'alice' } });
console.log({ auth: sign(`${socketId}:${channel}:${channelData}`), channel_data: channelData });

The authorization decision is the callback in routes/channels.php; Echo posts to the auth endpoint once per channel, so it is both authentication of the HTTP caller and authorization of the channel. Tokens never travel on the socket. The signature binds a socket id to a channel, so a signature captured from one connection is useless on another. The consequence is that revoking access takes effect on the next subscribe, not on existing subscriptions: to cut off a live subscription immediately the server has to close the connection, so keep channel names specific to a conversation and use a new name when its membership changes.

XMPP: SASL and server-side authorization

XMPP authenticates inside the stream with SASL, negotiated in the stream features after open (over WebSocket, RFC 7395 frames carry the same XML). The mechanisms that matter for a browser client:

  • SCRAM-SHA-256 (or -512, or -1) proves knowledge of the password without sending it (RFC 7677). Prefer it to PLAIN, which servers should offer only over an encrypted connection. A browser cannot use the -PLUS channel-binding variants because the WebSocket API exposes no TLS channel data.

  • OAUTHBEARER and X-OAUTH2 carry an access token instead of a password; ejabberd advertises X-OAUTH2, so a web application can issue tokens and keep the password out of the page (see XMPP in the Browser).

  • FAST (XEP-0484) lets a client that logged in once obtain a server-issued token for later logins without a password. The token has an expiry, the server may rotate it in the success response, and a user can revoke it. Its status is Proposed (version 0.2.0, 2024-06-30), so check server and client support before relying on it.

Authorization is the server’s job as well: roster and presence subscriptions are explicit approvals, a multi-user chat room enforces affiliations (owner, admin, member, outcast) and roles, and a server restricts who may register an account or reach the HTTP endpoints. Mapping Chatterbox rooms to XMPP rooms therefore moves canJoin() into server configuration. The mechanisms, the captured stream, and the server options are on XMPP Fundamentals, XMPP Servers and XMPP Chat Features. Identity in a stanza is stamped by the server from the authenticated stream, as the from of a Chatterbox broadcast is, so a client cannot forge it.

Producing the tokens: OAuth 2.0 and OpenID Connect

The example’s /api/login is a stand-in. In a real system the browser obtains its tokens from an authorization server, and the WebSocket layer must accept only the right ones:

  • Use the authorization code flow with PKCE from a browser application, or a backend-for-frontend that keeps tokens out of the page and hands the page a cookie (Browser-Based Apps, Authorization Code and PKCE).

  • The WebSocket or ticket endpoint should accept access tokens meant for the chat API, with the issuer, audience, algorithm and expiry checked, never an ID token (ID Tokens vs Access Tokens). The example verifies a signed JWT with jose, pinning HS256, iss and aud; see JWT and the JOSE Family for the validation rules and key handling. For opaque tokens, call introspection when minting the ticket (Opaque Tokens, Introspection and Revocation).

  • Map scopes and claims to the room policy: a chat:staff scope, a role claim or a group claim is what canJoin() reads (Scopes, Claims and Permissions). Copy the values into the ticket when it is minted, so the WebSocket server does not need to know the identity provider.

  • Short access-token lifetimes and refresh tokens decide how often the expiry path above runs (Access and Refresh Tokens). The refresh token stays in the HTTP layer.

  • Logout in the identity provider should end the sockets too: feed back-channel logout or session events into the revocation channel (Logout and Session Management).

  • Sender-constrained tokens (DPoP, mutual TLS) bind a token to a key. The DPoP proof travels in an HTTP header, which a browser cannot add to a WebSocket handshake, so apply them at the ticket endpoint and let the ticket stand for the connection (Sender-Constrained Tokens).

  • General guidance for all of this is in Security Best Practices.

Per-stack summary

Stack Identity on the connection Expiry and permissions

Chatterbox with ws or uWebSockets.js

Cookie, query ticket, subprotocol ticket or hello ticket, as above

Timer plus per-frame check; revocation through your own backplane

Socket.IO

auth option read in io.use(); io.engine.use() for cookies and sessions

Disconnect and reconnect; middleware per namespace

python-socketio

The same auth option, passed to the connect handler, which refuses the connection by raising ConnectionRefusedError

Disconnect and reconnect, as for Socket.IO; see python-socketio

Django Channels

Session cookie through AuthMiddlewareStack, or a ticket in hello

Re-check in consumers; origin validators

SignalR

Cookie, or access_token in the query string through accessTokenFactory

CloseOnAuthenticationExpiration; refresh in newer releases; [Authorize]

Spring WebFlux and STOMP

Handshake through Spring Security; STOMP CONNECT headers

Message authorization manager for STOMP; manual for raw handlers

Laravel Reverb and Pusher

Anonymous socket, signed channel subscriptions

Callback per channel; takes effect at the next subscribe

Atmosphere, Swoole

Framework-specific; see Atmosphere and Swoole

Same rules, written by hand

XMPP

SASL: SCRAM, token mechanisms, FAST

Server-side affiliations and roles

Common errors and pitfalls

  • Authenticating the handshake once and trusting the connection forever. Pair the check with an expiry timer and a revocation path.

  • Accepting any Origin or none. A missing Origin is fine for non-browser clients only if they still authenticate; a wrong one is refused. Match exact origins, not substrings or wildcards.

  • Long-lived tokens in the URL. Use a one-time ticket, or send the token in the first message.

  • Echoing the credential in the subprotocol reply. Select only the protocol name.

  • Reusable tickets. Without GETDEL (or an equivalent atomic delete) two connections can redeem the same ticket.

  • Reading identity from the payload. The server stamps from.

  • Authorizing only at join. A user removed from a room after joining keeps receiving; revoke through the same channel that handles logout.

  • Ignoring the close code on the client. Retrying the same credential forever after 4401, or never retrying after a network error, are both bugs. Distinguish them.

  • Logging tokens, tickets and URLs with credentials. Filter ticket and access_token from access logs and error reports.

  • Treating a failed handshake as an event the page can inspect. The browser exposes only error and close code 1006; use hello errors and application close codes after the upgrade for details.

  • Unbounded unauthenticated connections. Apply deadline, size, rate and per-IP limits before hello.

  • One credential for everything. Give the WebSocket ticket a narrow audience and short life; do not reuse the session cookie value or the refresh token as a ticket.