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 1000+ 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
Recommended Reading
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
}
});
Additional Resources
- 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
Partnering 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.
External Resources
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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.