Back to Blog

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.

Viprasol Tech Team
October 23, 2026
13 min read

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


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 โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.