WebSocket Authentication: Token-Based Auth, Reconnection, and Connection-Level vs Message-Level
Implement production WebSocket authentication: validate JWT tokens during handshake, handle token refresh during long-lived connections, implement automatic reconnection with exponential backoff, and choose between connection-level and message-level authorization.
WebSocket authentication is trickier than HTTP authentication because connections are long-lived. A token valid at connection time may expire hours later while the connection is still open. If you don't handle token refresh for WebSockets, users get silent disconnections โ their messages stop delivering, and they don't know why.
The two design decisions: where to authenticate (handshake only, or per-message), and how to handle token expiry during an active connection.
Handshake Authentication
Authentication happens during the WebSocket upgrade request โ before the connection is established:
// src/websocket/auth.ts
import { IncomingMessage } from "http";
import { verify, JsonWebTokenError, TokenExpiredError } from "jsonwebtoken";
import { URL } from "url";
interface AuthenticatedUser {
userId: string;
tenantId: string;
email: string;
roles: string[];
tokenExp: number; // Unix timestamp when token expires
}
export async function authenticateWebSocket(
request: IncomingMessage
): Promise<AuthenticatedUser | null> {
let token: string | null = null;
// Strategy 1: Token in query parameter (most common for WebSockets)
// ?token=eyJ... in the upgrade URL
if (request.url) {
const url = new URL(request.url, "http://localhost");
token = url.searchParams.get("token");
}
// Strategy 2: Token in Authorization header
// Some WebSocket clients support custom headers
if (!token) {
const authHeader = request.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice(7);
}
}
// Strategy 3: Session cookie (for browser clients)
if (!token) {
const cookieHeader = request.headers["cookie"];
token = extractSessionToken(cookieHeader ?? "");
}
if (!token) return null;
try {
const payload = verify(token, process.env.JWT_SECRET!) as AuthenticatedUser & {
sub: string;
exp: number;
};
return {
userId: payload.sub,
tenantId: payload.tenantId,
email: payload.email,
roles: payload.roles ?? [],
tokenExp: payload.exp,
};
} catch (error) {
if (error instanceof TokenExpiredError) {
// Token expired โ reject. Client should connect with a fresh token.
console.log("WebSocket connection rejected: token expired");
} else if (error instanceof JsonWebTokenError) {
console.warn("WebSocket connection rejected: invalid token");
}
return null;
}
}
// src/websocket/server.ts
import { WebSocketServer, WebSocket } from "ws";
import { authenticateWebSocket } from "./auth";
const wss = new WebSocketServer({ noServer: true });
// Handle HTTP upgrade to WebSocket
httpServer.on("upgrade", async (request, socket, head) => {
const user = await authenticateWebSocket(request);
if (!user) {
// Reject with 401 before upgrading
socket.write(
"HTTP/1.1 401 Unauthorized\r\n" +
"WWW-Authenticate: Bearer\r\n" +
"Connection: close\r\n\r\n"
);
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request, user);
});
});
// Connection handler โ user is authenticated at this point
wss.on("connection", (ws: WebSocket, request: IncomingMessage, user: AuthenticatedUser) => {
// Store user context on the connection
(ws as any).__user = user;
(ws as any).__connectedAt = Date.now();
// Schedule token expiry check
scheduleTokenExpiryCheck(ws, user);
ws.send(JSON.stringify({
type: "connected",
userId: user.userId,
serverTime: Date.now(),
}));
ws.on("message", (data) => {
handleMessage(ws, user, JSON.parse(data.toString()));
});
});
Token Expiry Handling
Tokens expire while the connection is open. Strategies:
// src/websocket/token-refresh.ts
// Strategy A: Server sends a "token_expiring" warning before expiry
// Client refreshes its token and sends it back
function scheduleTokenExpiryCheck(ws: WebSocket, user: AuthenticatedUser) {
const expiresInMs = user.tokenExp * 1000 - Date.now();
const warningMs = Math.max(0, expiresInMs - 5 * 60 * 1000); // 5 min before expiry
// Send warning 5 minutes before token expires
const warningTimer = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "token_expiring",
expiresAt: user.tokenExp,
refreshIn: 5 * 60, // seconds
}));
}
}, warningMs);
// Force close if token expires and client hasn't refreshed
const expiryTimer = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "token_expired" }));
ws.close(4001, "Token expired");
}
}, expiresInMs);
ws.on("close", () => {
clearTimeout(warningTimer);
clearTimeout(expiryTimer);
});
}
// Handle token refresh message from client
function handleMessage(ws: WebSocket, user: AuthenticatedUser, message: any) {
if (message.type === "refresh_token") {
refreshConnectionToken(ws, message.refreshToken);
return;
}
// ... other message handling
}
async function refreshConnectionToken(ws: WebSocket, refreshToken: string) {
try {
const { accessToken } = await exchangeRefreshToken(refreshToken);
const newUser = await authenticateWebSocket({ // Re-validate
headers: { authorization: `Bearer ${accessToken}` },
} as any);
if (!newUser) {
ws.close(4001, "Refresh failed");
return;
}
// Update stored user context
(ws as any).__user = newUser;
// Reschedule expiry check
scheduleTokenExpiryCheck(ws, newUser);
ws.send(JSON.stringify({
type: "token_refreshed",
expiresAt: newUser.tokenExp,
}));
} catch {
ws.close(4001, "Refresh failed โ please reconnect");
}
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Client-Side Reconnection with Exponential Backoff
// src/lib/websocket-client.ts
// Resilient WebSocket client with automatic reconnection
interface WebSocketClientOptions {
getToken: () => Promise<string>; // Async getter โ always returns fresh token
onMessage: (message: unknown) => void;
onConnected?: () => void;
onDisconnected?: () => void;
}
export class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionallyClosed = false;
private readonly MAX_RECONNECT_ATTEMPTS = 10;
private readonly BASE_DELAY_MS = 1000;
private readonly MAX_DELAY_MS = 30_000;
constructor(
private readonly url: string,
private readonly options: WebSocketClientOptions
) {}
async connect(): Promise<void> {
this.intentionallyClosed = false;
await this.createConnection();
}
private async createConnection(): Promise<void> {
// Always get a fresh token for each connection attempt
let token: string;
try {
token = await this.options.getToken();
} catch {
console.error("Failed to get auth token for WebSocket");
this.scheduleReconnect();
return;
}
const wsUrl = `${this.url}?token=${encodeURIComponent(token)}`;
this.ws = new WebSocket(wsUrl);
this.ws.addEventListener("open", () => {
this.reconnectAttempts = 0; // Reset on successful connection
this.options.onConnected?.();
});
this.ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
// Handle server-initiated token refresh
if (message.type === "token_expiring") {
this.handleTokenExpiring();
return;
}
if (message.type === "token_expired") {
// Server closed the connection due to expiry โ reconnect with fresh token
return;
}
this.options.onMessage(message);
});
this.ws.addEventListener("close", (event) => {
this.options.onDisconnected?.();
if (!this.intentionallyClosed) {
// Closed unexpectedly โ reconnect
if (event.code === 4001) {
// Auth failure โ get fresh token before reconnecting
console.warn("WebSocket auth failed, reconnecting with fresh token");
}
this.scheduleReconnect();
}
});
this.ws.addEventListener("error", (error) => {
console.error("WebSocket error:", error);
// close event will fire after error, triggering reconnect
});
}
private async handleTokenExpiring(): Promise<void> {
try {
const token = await this.options.getToken(); // Gets a refreshed token
this.send({ type: "refresh_token", token });
} catch {
// Can't refresh โ let the connection expire naturally and reconnect
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
console.error("Max WebSocket reconnection attempts reached");
return;
}
// Exponential backoff with jitter
const delay = Math.min(
this.BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts) +
Math.random() * 1000, // Jitter: prevent thundering herd
this.MAX_DELAY_MS
);
this.reconnectAttempts++;
console.log(`WebSocket reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => this.createConnection(), delay);
}
send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
close(): void {
this.intentionallyClosed = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close(1000, "Normal closure");
}
}
Connection-Level vs Message-Level Authorization
// Connection-level: authorize once at handshake
// โ
Simple, low overhead
// โ Can't change permissions without reconnecting
// Message-level: check permissions on every message
// โ
Handles permission changes mid-connection
// โ
Room/channel-level access control
// โ Higher overhead (check on every message)
// Best pattern: connection-level auth + message-level authorization
function handleMessage(ws: WebSocket, user: AuthenticatedUser, message: any) {
switch (message.type) {
case "join_channel":
// Authorization: can this user access this channel?
if (!userCanAccessChannel(user, message.channelId)) {
ws.send(JSON.stringify({
type: "error",
code: "FORBIDDEN",
message: "You don't have access to this channel",
}));
return;
}
joinChannel(ws, user, message.channelId);
break;
case "send_message":
// Check both channel access AND write permission
if (!userCanWriteToChannel(user, message.channelId)) {
ws.send(JSON.stringify({ type: "error", code: "FORBIDDEN" }));
return;
}
broadcastToChannel(message.channelId, {
type: "new_message",
from: user.userId,
content: message.content,
timestamp: Date.now(),
});
break;
}
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Socket.io Authentication Middleware
// src/websocket/socketio-auth.ts
import { Server } from "socket.io";
export function configureSocketAuth(io: Server) {
io.use(async (socket, next) => {
const token =
socket.handshake.auth.token || // Preferred: auth object
socket.handshake.headers.authorization?.slice(7) || // Header fallback
socket.handshake.query.token; // Query param fallback
if (!token) {
return next(new Error("Authentication required"));
}
try {
const user = await verifyToken(token as string);
socket.data.user = user;
// Auto-join user's personal room for targeted messages
socket.join(`user:${user.userId}`);
socket.join(`tenant:${user.tenantId}`);
next();
} catch (error) {
// Specific error message helps client distinguish expired vs invalid
if (error instanceof TokenExpiredError) {
next(new Error("TOKEN_EXPIRED"));
} else {
next(new Error("INVALID_TOKEN"));
}
}
});
}
// Client: Socket.io connection with auth
import { io } from "socket.io-client";
const socket = io("wss://api.viprasol.com", {
auth: {
token: await getAccessToken(), // Always get fresh token
},
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
reconnectionAttempts: 10,
});
socket.on("connect_error", async (error) => {
if (error.message === "TOKEN_EXPIRED") {
// Refresh token and update auth for next reconnection attempt
const newToken = await refreshAccessToken();
socket.auth = { token: newToken };
socket.connect(); // Retry with new token
}
});
See Also
- WebSocket Scaling โ horizontal scaling with Redis pub/sub
- API Security Best Practices โ JWT security
- GraphQL Subscriptions โ subscriptions vs WebSockets
- React Server Actions โ server-side auth patterns
Working With Viprasol
WebSocket authentication edge cases โ token expiry during long connections, reconnection storms after server restarts, per-channel authorization โ are where real-time systems fail in production. We design WebSocket authentication architectures that handle token lifecycle correctly, reconnect gracefully, and enforce authorization at both connection and message level.
Real-time systems engineering โ | Talk to our engineers โ
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.