Rotating WebSocket Tokens Without Dropping Connections #

Access tokens live fifteen minutes. WebSocket connections live eight hours. Something has to give, and in most systems what gives is correctness: the token is checked once at the upgrade and then never again, so a session that was revoked at 09:05 keeps streaming private data until the user closes the tab. The alternative most teams reach for — closing the socket when the token expires — is worse in a different way, producing a reconnect and a full resynchronisation every fifteen minutes for every user.

Neither is necessary. A WebSocket is bidirectional, so the client can hand the server a fresh token over the connection it already has.

Root cause #

HTTP authorisation is re-evaluated on every request, so a short token lifetime naturally bounds the damage of a leaked or revoked credential. A WebSocket has exactly one request — the upgrade — and everything after it is frames on an already-authorised connection. Authorisation therefore becomes a property of the connection, evaluated once, at a moment that recedes further into the past with every minute the socket stays open.

Two distinct problems follow. Expiry: the token the client presented is no longer valid, but nothing re-checks it. Revocation: an administrator disabled the account or the user logged out elsewhere, and there is no request boundary at which that decision can take effect.

The fix for expiry is in-band rotation — the client sends a new token before the old one lapses, and the server updates the connection’s authorisation state in place. The fix for revocation is a subscription to revocation events, because no amount of token freshness helps if the token is still cryptographically valid but no longer should be.

One connection, three token generations A long-lived socket stays authorised through successive tokens sent in band before each expiry; when a refresh fails to arrive the server allows a short grace window and then closes with code 4401. One connection, three token generations token A validity grace window upgrade with token A (0min) client sends token B (12min) token A would have expired (15min) client sends token C (27min) no refresh — grace starts (38min) close 4401 (42min) The socket never closes for expiry alone — only for a refresh that never arrives
Rotation happens well before expiry, so a single slow refresh has room to recover before anything is dropped.

Resolution #

Keep the connection’s authorisation state in one place, let an in-band frame replace it, and sweep for expiry rather than trusting a timer per connection.

import { WebSocket } from 'ws';
import { jwtVerify } from 'jose';

const REFRESH_BEFORE_MS = 120_000; // client should refresh this long before expiry
const GRACE_PERIOD_MS = 60_000; // tolerated overrun before we close
const SWEEP_INTERVAL_MS = 15_000;

interface AuthState {
userId: string;
scopes: Set<string>;
expiresAtMs: number;
jti: string; // token id, for revocation matching
}

const auth = new WeakMap<WebSocket, AuthState>();

/** Handle an in-band { t: 'auth.refresh', token } frame. */
export async function onRefresh(socket: WebSocket, token: string): Promise<void> {
const current = auth.get(socket);
let claims;
try {
({ payload: claims } = await jwtVerify(token, KEYS, { audience: 'ws', issuer: ISSUER }));
} catch {
socket.send(JSON.stringify({ t: 'auth.rejected', reason: 'invalid' }));
return; // keep the old state: a bad refresh must not deauthorise
}

// The refreshed token must belong to the same subject. Swapping identity on a
// live socket would let one user inherit another's subscriptions.
if (current && claims.sub !== current.userId) {
socket.close(4403, 'identity change not permitted');
return;
}

auth.set(socket, {
userId: String(claims.sub),
scopes: new Set(String(claims.scope ?? '').split(' ').filter(Boolean)),
expiresAtMs: Number(claims.exp) * 1000,
jti: String(claims.jti),
});
socket.send(JSON.stringify({ t: 'auth.accepted', expiresAt: Number(claims.exp) }));
}

/** One sweep for the whole server — not one timer per connection. */
export function startExpirySweep(clients: Set<WebSocket>): NodeJS.Timeout {
return setInterval(() => {
const now = Date.now();
for (const socket of clients) {
const state = auth.get(socket);
if (!state) continue;

// Nudge the client early so a slow refresh has time to complete.
if (state.expiresAtMs - now < REFRESH_BEFORE_MS) {
socket.send(JSON.stringify({ t: 'auth.refresh_required' }));
}
// Past expiry plus grace: the client is not refreshing. 4401 tells it to
// fetch a new token and reconnect rather than retry blindly.
if (now > state.expiresAtMs + GRACE_PERIOD_MS) {
socket.close(4401, 'token expired');
}
}
}, SWEEP_INTERVAL_MS);
}

/** Revocation arrives out of band — via pub/sub — and must land immediately. */
export function onRevocation(clients: Set<WebSocket>, revokedJti: string, userId: string): void {
for (const socket of clients) {
const state = auth.get(socket);
if (!state) continue;
if (state.jti === revokedJti || state.userId === userId) socket.close(4403, 'revoked');
}
}

Authorisation checks on individual messages should read from auth.get(socket) rather than from anything captured in a closure at connection time. That is the subtle bug rotation introduces: a handler that closed over scopes during the upgrade keeps the old scopes forever, so a downgrade of permissions silently does not apply.

In-band refresh versus reconnect-to-refresh The server asks for a refresh, the client fetches a new token over HTTP and sends it in band, and the same socket continues; the alternative path closes the connection and pays for a full reconnect and resynchronisation. In-band refresh versus reconnect-to-refresh Client Server Auth API auth.refresh_required POST /token (refresh cookie) new access token auth.refresh frame auth.accepted — same socket alternative: close 4401 + full reconnect The in-band path costs one round trip; the reconnect path costs a handshake, a resume and often a snapshot
Both paths end with a valid token. Only one of them keeps the stream running.

The cost difference is not marginal. Reconnecting to refresh pays for a TCP handshake, a TLS negotiation, the upgrade, a resume exchange and often a snapshot — all of it repeated on every rotation interval, for every connected user.

Cost of one token rotation, by strategy An in-band refresh costs a single round trip, while a reconnect pays for handshake, TLS and upgrade round trips plus state transfer, an order of magnitude more per rotation. Cost of one token rotation, by strategy one refresh per connection every 15 minutes Network round trips Server work State transfer In-band refresh 48 ms 180 ms Reconnect (resume) 235 ms 180 ms 420 ms Reconnect (snapshot) 625 ms
At 40,000 connections rotating every fifteen minutes, the difference is roughly 45 connection setups per second you simply do not perform.

Verification #

Prove that a live socket both accepts a new token and refuses to outlive a revoked one.

# Watch a rotation happen without a reconnect: connection count must stay flat
curl -s localhost:9090/metrics | grep -E 'ws_connections_active|ws_auth_(refresh|reject)_total'

# Force a revocation and confirm the socket closes within one sweep interval
redis-cli publish auth:revoked '{"jti":"tok_8811","userId":"u_42"}'

Two assertions matter. During a normal rotation ws_connections_active does not dip — if it does, something is closing the socket that should not be. After a revocation publish, the affected connections close within SWEEP_INTERVAL_MS, and the close code observed by the client is 4403, not 1006. A 1006 there means the socket was destroyed rather than closed, and the client will retry as if it were a network fault.

Operational checklist #

FAQ #

Why not just put the token in a query parameter and reconnect? #

Query parameters end up in access logs, browser history and proxy logs, which is why validating JWT on the WebSocket upgrade recommends a subprotocol header or a short-lived ticket instead. Reconnecting to refresh also throws away every advantage of a persistent connection: a handshake, a TLS negotiation, a resume and often a snapshot, every fifteen minutes, per user.

How does the client know when to refresh? #

Two mechanisms, and you want both. The server sends auth.refresh_required as the authoritative nudge, and the client also schedules its own refresh from the exp claim it already holds. Relying only on the server means a missed frame leaves the client unaware; relying only on the client means a server-side policy change cannot accelerate rotation.

Does an expired token invalidate messages already delivered? #

No, and it should not try to. Authorisation is evaluated at the moment of the action; messages delivered while the token was valid were validly delivered. What must stop is new activity — subscriptions, sends, reads — which is why the check belongs on each message rather than only at connection time.

What happens to messages sent during the grace window? #

Continue serving them at the existing scopes. The grace window exists precisely so a slow refresh does not break a working session, and cutting service while still holding the connection open gives the user a UI that looks live but silently fails. If your threat model cannot tolerate that, shorten the grace window rather than half-serving it.

Does this work with Socket.IO? #

Yes, and it is slightly easier: Socket.IO’s socket.data gives you a natural place to hold the auth state, and a custom auth.refresh event needs no extra plumbing. The one thing to watch is middleware — Socket.IO middleware runs at connection time only, so any authorisation logic you put there is subject to exactly the staleness this page is about.

Back to WebSocket Authentication & Authorization