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

Token expiry on long-lived sockets in Kafka & WebSockets

Your JWT expires after 15 minutes. The socket doesn't.

Expona Admin9 min read

Token expiry on long-lived sockets: The disconnect nobody prepares for

You deploy a real-time trading dashboard with JWT authentication set to expire after 15 minutes. Your WebSocket connection stays open for hours. Three hours later, a user places a trade—the server silently accepts it because the old token never gets validated again. That's the core problem we're solving today.

Long-lived WebSocket connections decouple the client-server relationship from stateless HTTP's natural expiration cycle. When your authentication tokens expire but the socket remains open, you create a security gap where stale credentials remain active on sensitive operations. I've watched this happen in production ride-hailing platforms where driver authentication lapsed mid-shift, allowing lingering connections to perform protected actions.

Track expiry server-side per connection

The foundation of safe token management on WebSockets is tracking each connection's token expiry independently. When a client connects, extract the token's exp claim and store it alongside the connection metadata. Don't rely on the client to tell you when it expires—calculate the remaining time server-side and schedule cleanup or enforcement at that exact moment.

Here's how I structure this in a NestJS gateway:

typescript
import { WebSocketGateway, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, ConnectedSocket, MessageBody } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { Injectable } from '@nestjs/common';

interface ClientSession {
  userId: string;
  tokenExp: number; // Unix timestamp in seconds
  expiryTimeoutId: NodeJS.Timeout;
  lastValidatedAt: number;
}

@Injectable()
@WebSocketGateway({ namespace: 'trades' })
export class TradeGateway implements OnGatewayConnection, OnGatewayDisconnect {
  private clientSessions = new Map();

  constructor(private jwtService: JwtService) {}

  async handleConnection(@ConnectedSocket() socket: Socket) {
    try {
      const token = socket.handshake.auth.token;
      const decoded = this.jwtService.verify(token);

      const tokenExp = decoded.exp; // exp is in seconds
      const now = Math.floor(Date.now() / 1000);
      const timeUntilExpiry = (tokenExp - now) * 1000; // convert to ms

      if (timeUntilExpiry <= 0) {
        socket.disconnect(true);
        return;
      }

      const expiryTimeoutId = setTimeout(() => {
        console.log(`Token expired for user ${decoded.sub}. Disconnecting socket ${socket.id}`);
        socket.emit('token:expired', {
          message: 'Your authentication token has expired. Please reconnect.',
        });
        socket.disconnect(true);
      }, timeUntilExpiry);

      this.clientSessions.set(socket.id, {
        userId: decoded.sub,
        tokenExp,
        expiryTimeoutId,
        lastValidatedAt: now,
      });

      socket.emit('connection:ready', { expiresIn: timeUntilExpiry });
    } catch (err) {
      console.error('Connection auth failed:', err.message);
      socket.disconnect(true);
    }
  }

  handleDisconnect(@ConnectedSocket() socket: Socket) {
    const session = this.clientSessions.get(socket.id);
    if (session) {
      clearTimeout(session.expiryTimeoutId);
      this.clientSessions.delete(socket.id);
    }
  }
}

Notice the expiryTimeoutId. This timer fires exactly when the token expires, not a second before or after. Store it so you can clean it up if the user disconnects early. The lastValidatedAt field is your anchor point for the next section.

Ask the client to refresh before expiry

Waiting until expiry to act is reactive. Proactive token refresh before expiry minimizes the window where stale credentials can cause problems. Send a warning signal to the client 60–90 seconds before expiry, giving them time to fetch a fresh token and re-authenticate without losing their connection state.

I implement this with a second timer that fires earlier:

typescript
async handleConnection(@ConnectedSocket() socket: Socket) {
  try {
    const token = socket.handshake.auth.token;
    const decoded = this.jwtService.verify(token);

    const tokenExp = decoded.exp;
    const now = Math.floor(Date.now() / 1000);
    const timeUntilExpiry = (tokenExp - now) * 1000;
    const warningThreshold = 60 * 1000; // Warn 60 seconds before expiry

    if (timeUntilExpiry <= 0) {
      socket.disconnect(true);
      return;
    }

    // Timer 1: Warn the client to refresh
    const warningTimeoutId = setTimeout(() => {
      console.log(`Sending refresh warning to ${socket.id}`);
      socket.emit('token:expiring-soon', {
        expiresIn: warningThreshold,
        action: 'Please call refreshToken() to continue your session.',
      });
    }, Math.max(0, timeUntilExpiry - warningThreshold));

    // Timer 2: Force disconnect if not refreshed
    const expiryTimeoutId = setTimeout(() => {
      console.log(`Token expired for user ${decoded.sub}. Disconnecting socket ${socket.id}`);
      socket.emit('token:expired', {
        message: 'Your authentication token has expired. Reconnect to continue.',
      });
      socket.disconnect(true);
    }, timeUntilExpiry);

    this.clientSessions.set(socket.id, {
      userId: decoded.sub,
      tokenExp,
      expiryTimeoutId,
      warningTimeoutId,
      lastValidatedAt: now,
    });

    socket.emit('connection:ready', { expiresIn: timeUntilExpiry });
  } catch (err) {
    socket.disconnect(true);
  }
}

On the client side (React/Next.js example), handle the warning and refresh:

typescript
useEffect(() => {
  socket.on('token:expiring-soon', async (data) => {
    console.log('Token expiring soon. Refreshing...');
    try {
      const newToken = await refreshToken();
      socket.emit('auth:refresh', { token: newToken });
      console.log('Token refreshed successfully');
    } catch (err) {
      console.error('Token refresh failed:', err);
      // Optional: redirect to login or show warning
    }
  });

  socket.on('token:expired', (data) => {
    console.log('Token expired. Reconnecting required.');
    // Clear local state, redirect to login
  });

  return () => {
    socket.off('token:expiring-soon');
    socket.off('token:expired');
  };
}, [socket]);

The client receives token:expiring-soon, calls refreshToken() via HTTP, and sends the fresh token back over the socket. The server updates the session metadata with the new expiry time and resets both timers.

Disconnect when the token is no longer valid

Sometimes refresh fails. The user's role changes or their account is suspended. The token in memory is no longer trustworthy. You need a hard cutoff: if the token is invalid, disconnect immediately, no exceptions.

Implement a refresh handler that validates the new token and updates or rejects the connection:

typescript
@SubscribeMessage('auth:refresh')
async handleTokenRefresh(
  @ConnectedSocket() socket: Socket,
  @MessageBody() payload: { token: string }
) {
  try {
    const decoded = this.jwtService.verify(payload.token);
    const tokenExp = decoded.exp;
    const now = Math.floor(Date.now() / 1000);

    if (tokenExp <= now) {
      socket.emit('auth:refresh-failed', {
        reason: 'Token is already expired',
      });
      socket.disconnect(true);
      return;
    }

    const session = this.clientSessions.get(socket.id);
    if (!session || session.userId !== decoded.sub) {
      socket.emit('auth:refresh-failed', {
        reason: 'Session mismatch or user changed',
      });
      socket.disconnect(true);
      return;
    }

    // Clear old timers
    clearTimeout(session.expiryTimeoutId);
    clearTimeout(session.warningTimeoutId);

    // Set new timers with updated expiry
    const timeUntilExpiry = (tokenExp - now) * 1000;
    const warningThreshold = 60 * 1000;

    const newWarningTimeoutId = setTimeout(() => {
      socket.emit('token:expiring-soon', { expiresIn: warningThreshold });
    }, Math.max(0, timeUntilExpiry - warningThreshold));

    const newExpiryTimeoutId = setTimeout(() => {
      socket.disconnect(true);
    }, timeUntilExpiry);

    session.tokenExp = tokenExp;
    session.expiryTimeoutId = newExpiryTimeoutId;
    session.warningTimeoutId = newWarningTimeoutId;
    session.lastValidatedAt = now;

    socket.emit('auth:refreshed', { expiresIn: timeUntilExpiry });
  } catch (err) {
    console.error('Token refresh validation failed:', err.message);
    socket.emit('auth:refresh-failed', { reason: 'Invalid token' });
    socket.disconnect(true);
  }
}

This handler runs every refresh cycle. If the new token is invalid, we don't silently accept it—we disconnect. The user must re-authenticate by reconnecting.

Re-check permissions on sensitive actions

Storing token expiry server-side and managing timers is critical, but it's not a substitute for per-action authorization. Even with active sessions, validate that the current user still has permission to perform each sensitive operation. This catches role changes, account suspensions, and other state shifts that happen between WebSocket messages.

In my trading platform, every trade execution re-validates the user's trading tier and account status:

typescript
@SubscribeMessage('trade:execute')
async handleTradeExecute(
  @ConnectedSocket() socket: Socket,
  @MessageBody() payload: { symbol: string; quantity: number; side: string }
) {
  const session = this.clientSessions.get(socket.id);

  if (!session) {
    socket.emit('trade:error', { message: 'No active session' });
    return;
  }

  const now = Math.floor(Date.now() / 1000);
  if (session.tokenExp <= now) {
    socket.emit('trade:error', { message: 'Token expired' });
    socket.disconnect(true);
    return;
  }

  try {
    // Fetch fresh user state from database (not from token cache)
    const user = await this.usersService.findById(session.userId);

    if (!user.active) {
      socket.emit('trade:error', { message: 'Account suspended' });
      socket.disconnect(true);
      return;
    }

    if (!user.canTrade) {
      socket.emit('trade:error', { message: 'Trading permission revoked' });
      return; // Don't disconnect; user might regain permission later
    }

    if (user.tradingTier < this.getTierRequirement(payload)) {
      socket.emit('trade:error', { message: 'Insufficient trading tier' });
      return;
    }

    // Perform the trade
    const result = await this.tradesService.execute(user.id, payload);
    socket.emit('trade:executed', result);
  } catch (err) {
    socket.emit('trade:error', { message: err.message });
  }
}

Notice the distinction: account suspension triggers a disconnect, but permission revocation might not. It depends on your business logic. The key principle is: validate every sensitive action against the current user state, not just the cached session.

Common mistakes

  • Trusting the client's expiry claim. Don't accept a message like { expiresAt: 123456 } from the client. Always calculate expiry server-side from the token's exp claim or your JWT payload.
  • Setting a single long-lived timer. If you set one timeout for the entire session duration without updating it on refresh, you'll disconnect mid-action. Update timers every time the token is refreshed.
  • Forgetting to clear timers on disconnect. Orphaned timeouts continue consuming memory and fire after the connection is gone. Always clear them in handleDisconnect or equivalent.
  • Assuming expiry equals authorization revocation. A valid, non-expired token doesn't mean the user still has permission. Role changes and account updates happen outside the JWT lifecycle. Check permissions per action.
  • Sending sensitive data before re-validating. If a user's permission changes between WebSocket messages, don't emit account data before checking their current access level.

Key takeaways

  • Track token expiry per connection server-side using the JWT's exp claim. Set two timers: one to warn the client 60–90 seconds before expiry, one to force disconnect at expiry.
  • Implement a refresh handler that accepts new tokens over the WebSocket, validates them, updates session metadata, and resets expiry timers.
  • Disconnect immediately if a token refresh fails, is already expired, or doesn't match the current session's user ID.
  • Validate user permissions and account state on every sensitive action, not just at connection time. Fetch fresh data from your database to catch role changes and suspensions.
  • Always clean up timers when connections close to prevent memory leaks and orphaned callbacks.
---
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