Skip to content
NestJS ns websockets 5 min read

Auth & Scaling WebSockets

Production WebSocket gateways have two concerns that HTTP routes mostly hide from you: every connection is long-lived and stateful, so you must authenticate it once at the handshake rather than per request, and a single broadcast must reach clients no matter which Node process they happen to be connected to. This page shows how to verify a JWT during the Socket.IO handshake, wrap that logic in a reusable custom IoAdapter, and fan broadcasts out across every instance with the Socket.IO Redis adapter.

Authenticating the handshake

A WebSocket connection begins life as an HTTP upgrade request, which means the client can attach a token before the socket is ever established. Socket.IO surfaces this on the handshake — either through the auth payload (preferred) or via query parameters and headers. Verifying the token at this point lets you reject bad clients before they consume a slot, and lets you stash the authenticated user on the socket for the lifetime of the connection.

// chat.gateway.ts
import {
  OnGatewayConnection,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';

interface AuthedSocket extends Socket {
  user?: { sub: string; username: string };
}

@WebSocketGateway({ cors: { origin: true } })
export class ChatGateway implements OnGatewayConnection {
  @WebSocketServer() server: Server;
  private readonly logger = new Logger(ChatGateway.name);

  constructor(private readonly jwt: JwtService) {}

  async handleConnection(client: AuthedSocket) {
    const token =
      client.handshake.auth?.token ??
      (client.handshake.headers.authorization?.split(' ')[1] as string);

    try {
      client.user = await this.jwt.verifyAsync(token);
      this.logger.log(`Connected: ${client.user.username} (${client.id})`);
    } catch {
      this.logger.warn(`Rejected handshake for ${client.id}`);
      client.disconnect(true);
    }
  }
}

The browser side passes the token through the auth option, which keeps it out of URLs and server logs:

import { io } from 'socket.io-client';

const socket = io('https://api.example.com', {
  auth: { token: localStorage.getItem('accessToken') },
});

Prefer handshake.auth over query strings. Query parameters leak into proxy logs, browser history, and Referer headers, whereas the auth payload is sent only in the upgrade body.

A custom IoAdapter

Disconnecting in handleConnection works, but it lets the socket open before slamming it shut, and it scatters auth logic across every gateway. A cleaner approach is a custom IoAdapter that runs a Socket.IO middleware on the underlying server. Middleware registered with server.use() runs during the handshake and can fail it outright with an Error, so unauthenticated clients never reach a gateway at all.

// adapters/auth-io.adapter.ts
import { IoAdapter } from '@nestjs/platform-socket.io';
import { INestApplicationContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ServerOptions, Server } from 'socket.io';

export class AuthIoAdapter extends IoAdapter {
  private readonly jwt: JwtService;

  constructor(private app: INestApplicationContext) {
    super(app);
    this.jwt = app.get(JwtService);
  }

  createIOServer(port: number, options?: ServerOptions): Server {
    const server: Server = super.createIOServer(port, options);

    server.use(async (socket, next) => {
      try {
        const token =
          socket.handshake.auth?.token ??
          socket.handshake.headers.authorization?.split(' ')[1];
        (socket as any).user = await this.jwt.verifyAsync(token);
        next();
      } catch {
        next(new Error('Unauthorized'));
      }
    });

    return server;
  }
}

Register it in main.ts after the app context exists, so the adapter can resolve JwtService from the DI container:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthIoAdapter } from './adapters/auth-io.adapter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useWebSocketAdapter(new AuthIoAdapter(app));
  await app.listen(3000);
}
bootstrap();

A client that fails the middleware receives a connect_error instead of ever connecting:

Output:

WebSocket connect_error: Unauthorized

Scaling across instances with Redis

Once you run more than one process — multiple PM2 workers, several pods, anything behind a load balancer — clients fragment across them. A socket connected to instance A holds no shared memory with instance B, so server.emit() only reaches the local process. The Socket.IO Redis adapter fixes this by relaying every broadcast through Redis Pub/Sub, so an emit on any instance fans out to all of them.

Install the adapter and a Redis client:

npm install @socket.io/redis-adapter redis

Wire it into the same custom adapter. The two Redis clients (one publishes, one subscribes) are created once and attached to the Socket.IO server:

// adapters/redis-io.adapter.ts
import { IoAdapter } from '@nestjs/platform-socket.io';
import { ServerOptions, Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

export class RedisIoAdapter extends IoAdapter {
  private adapterConstructor: ReturnType<typeof createAdapter>;

  async connectToRedis(url: string): Promise<void> {
    const pubClient = createClient({ url });
    const subClient = pubClient.duplicate();
    await Promise.all([pubClient.connect(), subClient.connect()]);
    this.adapterConstructor = createAdapter(pubClient, subClient);
  }

  createIOServer(port: number, options?: ServerOptions): Server {
    const server: Server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}
// main.ts
const redisAdapter = new RedisIoAdapter(app);
await redisAdapter.connectToRedis('redis://localhost:6379');
app.useWebSocketAdapter(redisAdapter);

With this in place, this.server.to('room-42').emit('message', payload) from a gateway on any pod reaches every member of room-42, wherever they connected. You can fold the auth middleware and Redis wiring into a single adapter — they compose cleanly in one createIOServer.

ConcernWithout Redis adapterWith Redis adapter
server.emit() reachLocal process onlyAll instances
Rooms / namespacesPer processCluster-wide
Sticky sessionsRequired for pollingStill required for HTTP long-polling fallback
Added dependencyNoneRedis + two Pub/Sub clients

Sticky sessions are still mandatory if you allow the HTTP long-polling transport, because successive polling requests must hit the same process. Forcing transports: ['websocket'] avoids this, at the cost of dropping the polling fallback.

Best practices

  • Verify tokens at the handshake (middleware or IoAdapter), not on every message — the connection is already trusted once established.
  • Use handshake.auth for tokens; keep them out of query strings and URLs.
  • Centralize auth in a custom IoAdapter so gateways stay focused on business logic.
  • Always force-disconnect (client.disconnect(true)) or fail middleware for invalid tokens so sockets do not linger.
  • Add the Redis adapter the moment you run more than one instance, otherwise broadcasts silently miss clients on other processes.
  • Keep sticky sessions on at the load balancer when polling is enabled, even with the Redis adapter.
  • Use a dedicated Redis instance (or database) for the Pub/Sub adapter so socket traffic does not contend with your cache.
Last updated June 14, 2026
Was this helpful?