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.authover query strings. Query parameters leak into proxy logs, browser history, andRefererheaders, whereas theauthpayload 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.
| Concern | Without Redis adapter | With Redis adapter |
|---|---|---|
server.emit() reach | Local process only | All instances |
| Rooms / namespaces | Per process | Cluster-wide |
| Sticky sessions | Required for polling | Still required for HTTP long-polling fallback |
| Added dependency | None | Redis + 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.authfor tokens; keep them out of query strings and URLs. - Centralize auth in a custom
IoAdapterso 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.