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.
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.
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.
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.
Related #
- WebSocket Authentication & Authorization — the upgrade-time checks this extends across the connection’s life.
- Validating JWT on the WebSocket Upgrade — where the first token is verified and why it should not travel in the URL.
- Enforcing Origin and CSRF Checks on WebSockets — the other half of upgrade-time defence.
- WebSocket Close Codes Explained — the 4401 and 4403 contract the client classifier depends on.
- Resuming WebSocket Sessions After Reconnect — what a forced reconnect costs when rotation is not in band.