Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 056 / 100

Authenticating WebSocket connections: a practical guide

Secure the handshake and every message.

Expona Admin10 min read

Authenticating WebSocket connections is where most teams discover that real-time security isn't just about encrypting the wire—it's about validating who's on the other end and what they're allowed to do. We're on day 56 of the 100-day Kafka & WebSockets series, and if you've been running WebSocket servers in production without proper authentication, this is the day that changes.

Unlike HTTP, where every request is independent and stateless by default, WebSocket connections are long-lived. That means the decisions you make during the handshake echo through every message for minutes, hours, or days. Get authentication wrong, and you're not just exposing a single endpoint—you're exposing a channel that a bad actor can exploit repeatedly.

Authenticate on connect — Validate a token during the handshake

The WebSocket handshake is your only real chance to verify identity before the connection opens. After that, you're in a different ballgame—every message flows over an already-established connection, and rejecting messages is noisier and more complicated than closing the handshake early.

Most real-world deployments I've seen use one of two patterns: Bearer tokens sent in the initial payload, or tokens in the Authorization header during the HTTP upgrade request. Here's how it looks in a NestJS gateway with JWT validation:

typescript
import { WebSocketGateway, OnGatewayConnection, WebSocketServer } from '@nestjs/websockets';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { UseGuards } from '@nestjs/common';

@WebSocketGateway({ namespace: '/trading' })
export class TradingGateway implements OnGatewayConnection {
  @WebSocketServer() server: Server;

  constructor(private jwtService: JwtService) {}

  async handleConnection(socket: Socket) {
    try {
      const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.split(' ')[1];
      
      if (!token) {
        socket.disconnect();
        return;
      }

      const decoded = this.jwtService.verify(token);
      socket.data.userId = decoded.sub;
      socket.data.roles = decoded.roles;
      
      console.log(`User ${decoded.sub} connected with roles: ${decoded.roles.join(',')}`);
    } catch (err) {
      console.error('Auth failed:', err.message);
      socket.disconnect();
    }
  }
}

Notice we're checking the token before anything else happens. If verification fails, we disconnect immediately. No edge cases, no half-authenticated state. The handshake either succeeds with a valid token, or it fails and the connection never opens.

In a raw WebSocket scenario (not Socket.IO), you'd handle this in the server upgrade event:

typescript
import ws from 'ws';
import jwt from 'jsonwebtoken';

const wss = new ws.Server({ noServer: true });

server.on('upgrade', (request, socket, head) => {
  const url = new URL(request.url, 'http://localhost');
  const token = url.searchParams.get('token');

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    request.userId = decoded.sub;
    
    wss.handleUpgrade(request, socket, head, (ws) => {
      wss.emit('connection', ws, request);
    });
  } catch (err) {
    socket.destroy();
  }
});

wss.on('connection', (ws, request) => {
  console.log(`User ${request.userId} connected`);
});

The critical difference: we validate before calling handleUpgrade. If the token is invalid, we destroy the socket at the TCP level, not after the WebSocket protocol is established.

Avoid tokens in URLs where possible — Query strings end up in logs

I learned this the hard way. A trading platform I helped build was leaking authentication tokens in reverse proxy logs, browser history, and CloudWatch. Why? Because the frontend was concatenating the token into the connection URL:

typescript
// DON'T DO THIS
const socket = io(`http://api.example.com/trading?token=${jwtToken}`);

Query strings live in:

  • Nginx/Apache access logs (plaintext or behind encryption that the logs don't use)
  • Browser developer tools and history
  • Referrer headers in logs when the page reloads
  • CloudWatch, DataDog, or other APM tools that log request URLs
  • Proxy servers and CDNs in between

Use the auth payload instead:

typescript
// DO THIS
const socket = io('http://api.example.com/trading', {
  auth: {
    token: jwtToken
  }
});

The auth object gets sent in the first message after the connection opens, not in the URL. It's still encrypted by TLS, and it doesn't scatter itself across every log in your infrastructure.

If you must support cookies (and sometimes you do for browser-based clients sharing auth state with HTTP endpoints), ensure they're:

  • HttpOnly — JavaScript can't read them, reducing XSS impact
  • Secure — Only sent over HTTPS/WSS
  • SameSite=Strict — Blocks CSRF attacks that try to open WebSocket connections to your server from another domain

Cookies are decoded and validated server-side during the handshake, same as any other token:

typescript
async handleConnection(socket: Socket) {
  try {
    const cookies = parseCookie(socket.handshake.headers.cookie);
    const sessionId = cookies.sessionId;
    
    if (!sessionId) {
      socket.disconnect();
      return;
    }

    const session = await this.sessionStore.get(sessionId);
    if (!session || session.expiresAt < Date.now()) {
      socket.disconnect();
      return;
    }

    socket.data.userId = session.userId;
  } catch (err) {
    socket.disconnect();
  }
}

Authorize per action — Joining a room requires permission checks too

Authentication proves you are who you say you are. Authorization decides what you're allowed to do. And in a real-time system, you need to authorize every significant action, not just the initial connection.

Here's a real scenario: a ride-hailing platform where drivers and passengers both connect to a WebSocket server. A driver can receive ride requests, but a passenger shouldn't be able to see which other passengers are in the system. And they certainly shouldn't be able to receive ride requests.

typescript
@SubscribeMessage('join-driver-pool')
async joinDriverPool(socket: Socket, data: { zoneId: string }) {
  const userId = socket.data.userId;
  const roles = socket.data.roles;

  // Authorization: only drivers can join the driver pool
  if (!roles.includes('driver')) {
    socket.emit('error', { code: 'UNAUTHORIZED', message: 'Only drivers can join the pool' });
    return;
  }

  // Authorization: driver can only join zones they're allowed to serve
  const allowedZones = await this.driverService.getAllowedZones(userId);
  if (!allowedZones.includes(data.zoneId)) {
    socket.emit('error', { code: 'FORBIDDEN', message: 'You are not authorized to serve this zone' });
    return;
  }

  // Add driver to the room
  socket.join(`driver-pool:${data.zoneId}`);
  console.log(`Driver ${userId} joined pool for zone ${data.zoneId}`);
}

Notice we're checking two things:

  1. Role-based access — Does the user have the driver role?
  2. Resource-based access — Is the user allowed to join this specific zone?

This pattern repeats for every message type. A passenger requesting a ride, a driver accepting a ride, anyone sending a chat message—all need authorization checks. The token tells you who you are; the authorization check tells you what you can do.

typescript
@SubscribeMessage('send-message')
async handleMessage(socket: Socket, data: { rideId: string; text: string }) {
  const userId = socket.data.userId;

  // Fetch the ride and verify this user is participant
  const ride = await this.rideService.get(data.rideId);
  if (!ride) {
    socket.emit('error', { code: 'NOT_FOUND', message: 'Ride not found' });
    return;
  }

  const isParticipant = [ride.driverId, ride.passengerId].includes(userId);
  if (!isParticipant) {
    socket.emit('error', { code: 'FORBIDDEN', message: 'You are not part of this ride' });
    return;
  }

  // Message is valid, broadcast to both parties
  this.server.to(`ride:${data.rideId}`).emit('message', {
    from: userId,
    text: data.text,
    timestamp: new Date()
  });
}

Handle expiry — Long connections outlive short tokens. Refresh or reconnect

WebSocket connections can stay open for days if both client and server allow it. But tokens expire. If your JWT is set to expire in 15 minutes and your WebSocket connection stays open for an hour, you have a mismatch.

You have two options: refresh the token before it expires, or let the connection die and make the client reconnect.

Option 1: Token refresh over the WebSocket

Send a refresh message to the server, which validates your refresh token (a longer-lived token stored separately) and returns a new access token:

typescript
@SubscribeMessage('refresh-token')
async handleTokenRefresh(socket: Socket, data: { refreshToken: string }) {
  try {
    const decoded = this.jwtService.verify(data.refreshToken, { secret: process.env.REFRESH_SECRET });
    
    // Verify this refresh token hasn't been revoked
    const isRevoked = await this.tokenRevocationService.isRevoked(decoded.jti);
    if (isRevoked) {
      throw new Error('Token has been revoked');
    }

    const newAccessToken = this.jwtService.sign(
      { sub: decoded.sub, roles: decoded.roles },
      { expiresIn: '15m' }
    );

    socket.emit('token-refreshed', { token: newAccessToken });
    console.log(`Token refreshed for user ${decoded.sub}`);
  } catch (err) {
    socket.disconnect();
  }
}

On the client, you'd refresh when the token is close to expiry (e.g., 1 minute before expiration):

typescript
class WebSocketClient {
  private accessToken: string;
  private refreshToken: string;
  private tokenExpiresAt: number;
  private refreshInterval: NodeJS.Timeout;

  connect() {
    this.socket = io('ws://api.example.com/trading', {
      auth: { token: this.accessToken }
    });

    this.socket.on('connect', () => {
      this.scheduleTokenRefresh();
    });

    this.socket.on('token-refreshed', (data) => {
      this.accessToken = data.token;
      this.tokenExpiresAt = Date.now() + (15 * 60 * 1000);
      this.scheduleTokenRefresh();
    });
  }

  private scheduleTokenRefresh() {
    if (this.refreshInterval) clearInterval(this.refreshInterval);
    
    const timeUntilExpiry = this.tokenExpiresAt - Date.now();
    const refreshAt = timeUntilExpiry - (1 * 60 * 1000); // Refresh 1 min before expiry

    this.refreshInterval = setTimeout(() => {
      this.socket.emit('refresh-token', { refreshToken: this.refreshToken });
    }, refreshAt);
  }
}

Option 2: Reconnect after expiry

Simpler, but means a brief disruption in service. The client reconnects when the token expires, or when the server closes the connection for an expired token.

I've seen both approaches in production. High-frequency trading platforms prefer refresh (zero disruption). Chat applications often reconnect (simpler code, acceptable UX). Choose based on your tolerance for dropped connections.

One thing I always do: track token expiry on the server too, as a safety check.

typescript
async handleConnection(socket: Socket) {
  const token = socket.handshake.auth.token;
  const decoded = this.jwtService.verify(token);
  
  socket.data.userId = decoded.sub;
  socket.data.tokenExpiresAt = decoded.exp * 1000; // Convert to milliseconds

  // Schedule a disconnect if token expires while connection is still open
  const expiresIn = socket.data.tokenExpiresAt - Date.now();
  socket.data.expiryTimeout = setTimeout(() => {
    socket.disconnect(true);
    console.log(`Token expired for user ${decoded.sub}, disconnecting`);
  }, expiresIn);
}

handleDisconnect(socket: Socket) {
  if (socket.data.expiryTimeout) {
    clearTimeout(socket.data.expiryTimeout);
  }
}

Common mistakes

  • Trusting the client to tell you when a token expires — The client lies. Validate expiry server-side, always.
  • Putting tokens in URLs — They end up in logs. Use auth payloads or cookies instead.
  • Only authenticating on connect — Validate authorization on every message. A user's permissions can change while they're connected.
  • Not disconnecting on failed auth — A partial handshake is worse than no connection. Fail fast and hard.
  • Forgetting to refresh or handle expiry — Long connections outlive short tokens. You need a strategy for this before it becomes a production fire.
  • Storing sensitive data in socket.data without encryption — It's in memory on the server, but if an attacker gets a foothold, it's exposed. Keep only what you need: userId and roles.

Key takeaways

  • Authenticate during the WebSocket handshake before any messages flow. Use the auth payload or headers, never query strings.
  • Authorize every significant action—joining a room, sending a message, performing a trade—not just the connection.
  • Handle token expiry explicitly. Either refresh the token before it expires, or let the connection die and reconnect. Don't leave it ambiguous.
  • Validate tokens on the server in every message handler that matters. The client's clock can't be trusted.
  • Close connections aggressively on auth failure. A half-authenticated state is a security hole.
  • Log authentication events (successful logins, failed attempts, token refreshes) so you can detect abuse patterns early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles