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
helloreply that carries theuserId. Everything the server later puts in afromfield 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 |
|
No |
Usable by Node.js, mobile and command-line clients, not by browsers. |
URL query string |
Yes ( |
Works everywhere. The URL ends up in access logs, proxy logs and monitoring, so only a short-lived, one-time value belongs there. |
|
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:
-
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. -
It opens
wss://chat.example.test/wswith thechatterbox.v1subprotocol. The server checksOriginagainst an allow-list during the handshake, and refuses a request that has anOriginoutside the list with HTTP 403. -
The first frame is
hellocarrying the ticket. The server redeems it with an atomicGETDEL, binds the identity to the connection and arms a timer for the credential’s expiry. A missing, replayed or expired ticket gets anunauthorizederror and close code4401. -
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.
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.
Cookie sessions
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.testcan openwss://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 theOriginheader 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 anyOrigin, so this stops browsers, which is the attack, and is not authentication. -
Cookie attributes. Set
HttpOnly(scripts cannot read the session),Secure(HTTPS only) andSameSite=LaxorStrict. ASameSitecookie 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 theOrigincheck. -
CSRF on the ticket endpoint. If
POST /api/ticketaccepts 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 checkOriginand 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, close4408), 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
hellobefore it succeeds: anything else getsunauthorizedand close4401.
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}`));
Source: ws README: Client authentication and Redis: GETDEL
The design decisions, in the order the code makes them:
-
The upgrade is handled by hand. The
wsdocumentation discouragesverifyClientand recommends authenticating in the HTTP server’supgradeevent, 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
Originoutside the allow-list is refused with 403. A request withoutOriginis allowed through to the ticket check, because it cannot be a browser. -
A subprotocol is required and selected explicitly.
handleProtocolsreturnschatterbox.v1and nothing else, so aticket.<value>entry offered by the client is read by the upgrade handler but never echoed. -
Three handshake paths and one
hellopath. A?ticket=value or aticket.subprotocol entry is redeemed during the handshake (failure: HTTP 401); a cookie session is looked up in Redis; otherwise the connection stays anonymous untilhello, which redeemspayload.token. All of them end inbind(). -
One-time and shared.
redeem()isGETDEL, atomic in Redis, so two instances racing for the same ticket cannot both succeed, and the 30-secondEXmakes unused tickets disappear. -
The credential’s lifetime is the connection’s lifetime.
bind()copiesexpfrom the ticket (which copied it from the access token or the session) and arms a timer that closes with4401and the reasontoken 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,joinedis the membership themessagehandler checks, andeditcompares the author. Failures are Chatterboxforbiddenerrors, which are not retryable. -
Identity is never read from the payload. The
fromfield of a broadcast comes fromconn.user. -
Revocation fans out.
POST /api/logoutpublishes the user name on a Redis channel; every instance closes that user’s sockets with4401.
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); } };
}
Source: MDN: WebSocket constructor and MDN: CloseEvent
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 |
|
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 |
The user’s permissions change |
Re-check on the next action, or close and re-authenticate |
|
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 = truein theMapHuboptions closes the connection when the token expires so that the client reconnects with a new token. The ASP.NET Core documentation for newer releases addsEnableAuthenticationRefreshon the server andwithAuthenticationRefresh()andrefreshAuthentication()on the JavaScript client, which swap the token on the live connection through arefreshroute next tonegotiate(proxies must forward it) without changing the user identifier. See SignalR for the version notes. -
Socket.IO. There is no refresh, because the
authobject is read once per connection attempt. The pattern is the one in the next section: disconnect on expiry and let the client connect again, withauthas 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 |
Room |
May it join |
The |
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 |
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
hellocloses the connection, and a room is joinable only if a rule says so. -
The identity comes from the connection. Never accept
userId,fromorrolein a payload. The server stamps them. -
Check where the data is loaded. The
edithandler 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-carolis protected by the rule incanJoin(), 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 itsPrincipalbecomes the WebSocket session’s. For STOMP,@EnableWebSocketSecuritywith anAuthorizationManagerofMessageMatcherDelegatingAuthorizationManagerauthorizes each inbound message by destination and type, and a CSRF token is required in theCONNECTframe by default. Spring WebFlux’s rawWebSocketHandlerhas 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.Useris the identity. See Authorization and Authentication and Identity. -
Django Channels.
AuthMiddlewareStackandscope["user"], checked inconnect()and in each handler; the Channels documentation suggests that a long-running consumer re-check the user withget_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, wherechannel_datais 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-PLUSchannel-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, pinningHS256,issandaud; 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:staffscope, aroleclaim or a group claim is whatcanJoin()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 |
Cookie, query ticket, subprotocol ticket or |
Timer plus per-frame check; revocation through your own backplane |
Socket.IO |
|
Disconnect and reconnect; middleware per namespace |
python-socketio |
The same |
Disconnect and reconnect, as for Socket.IO; see python-socketio |
Django Channels |
Session cookie through |
Re-check in consumers; origin validators |
SignalR |
Cookie, or |
|
Spring WebFlux and STOMP |
Handshake through Spring Security; STOMP |
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
Originor none. A missingOriginis 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
ticketandaccess_tokenfrom access logs and error reports. -
Treating a failed handshake as an event the page can inspect. The browser exposes only
errorand close code 1006; usehelloerrors 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.
Related pages
-
Security for cross-site hijacking, flooding and the security checklist.
-
Connection Lifecycle, Heartbeats and Reconnection for close codes, back-off and resume.
-
The WebSocket Protocol and The Browser WebSocket API for the handshake and the constraints of the browser API.
-
Message Design and Serialization for the Chatterbox envelope and errors.
-
What is CORS?, and the OAuth Reference section.