Authenticating a Socket

Browsers cannot set headers on a WebSocket, so the token travels in the handshake payload and is checked before connect.

Syntaxserver.use((socket: Socket, next: (err?: Error) => void) => void)

The browser's WebSocket API takes a URL and an optional subprotocol, and nothing else. There is no place for an Authorization header. socket.io's extraHeaders option does not rescue you either: in a browser it is ignored when the WebSocket transport is the only one enabled.

Where the token goes

socket.io reserves a slot in the handshake for this. The client passes auth, and the server reads socket.handshake.auth. A query parameter also works and is sometimes the only option for a raw ws client, but query strings end up in proxy and access logs, so prefer auth.

Checking it once, at connect

Identity belongs in a socket.io middleware: a function registered with server.use() on the server instance Nest hands to afterInit(). It runs once per connection attempt, before the socket connects and therefore before handleConnection(). Call next() to accept or next(err) to refuse; a refused client gets a connect_error event and no disconnect event fires. Store the verified identity on socket.data so handlers never re-verify.

Guards run per message

A guard is a class with canActivate() that decides whether a handler may run. On a gateway it runs for each @SubscribeMessage event, not at connect time. It reads the socket with context.switchToWs().getClient(), which is where per-event authorisation belongs: this user, this room, this action.

Example

Example · typescript
// src/events/events.gateway.ts
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
  ConnectedSocket,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

interface TokenPayload {
  sub: string;
  roles?: string[];
  exp: number; // seconds since the epoch
}

@WebSocketGateway({
  cors: { origin: 'https://app.example.com', credentials: true },
})
export class EventsGateway implements OnGatewayInit {
  private readonly logger = new Logger(EventsGateway.name);

  constructor(private readonly jwt: JwtService) {}

  afterInit(server: Server): void {
    // Registered once; the function itself runs per connection attempt,
    // before the socket connects and before handleConnection().
    server.use(async (socket: Socket, next: (err?: Error) => void) => {
      // Set by the client as io(url, { auth: { token } }).
      const token = socket.handshake.auth?.token;
      if (typeof token !== 'string' || token.length === 0) {
        return next(new Error('Missing token'));
      }

      try {
        const payload = await this.jwt.verifyAsync<TokenPayload>(token);

        // socket.data is a free-form object readable in every handler.
        socket.data.userId = payload.sub;
        socket.data.roles = payload.roles ?? [];

        // Nothing re-checks the token, so schedule the expiry yourself.
        const msLeft = payload.exp * 1000 - Date.now();
        const timer = setTimeout(() => {
          socket.emit('auth:expired');
          socket.disconnect(true);
        }, Math.max(msLeft, 0));
        socket.on('disconnect', () => clearTimeout(timer));

        next();
      } catch (error) {
        const err = new Error('Invalid token');
        // err.data reaches the client's connect_error handler.
        (err as Error & { data?: unknown }).data = { code: 'auth/invalid' };
        this.logger.warn(`handshake rejected: ${(error as Error).message}`);
        next(err);
      }
    });
  }

  @SubscribeMessage('whoami')
  whoami(@ConnectedSocket() client: Socket) {
    return { userId: client.data.userId as string };
  }
}

When to use it

  • Reject a socket at the handshake when its JWT is missing, expired or forged.
  • Put each authenticated socket in a user:<id> room so a job can push to that user.
  • Let only users with the admin role subscribe to an audit stream.
  • Disconnect a socket the moment the token it connected with expires.

More examples

A guard for per-message authorisation

Use a guard for the question that changes per message: may this user send this event. Guards registered globally with app.useGlobalGuards() or APP_GUARD do apply to gateways, unlike global exception filters. If a guard returns false instead of throwing, Nest raises a WsException with the message 'Forbidden resource'.

Example · typescript
// src/auth/ws-roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const WS_ROLES = 'ws:roles';
export const WsRoles = (...roles: string[]) => SetMetadata(WS_ROLES, roles);

// src/auth/ws-roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { WS_ROLES } from './ws-roles.decorator';

@Injectable()
export class WsRolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required =
      this.reflector.getAllAndOverride<string[]>(WS_ROLES, [
        context.getHandler(),
        context.getClass(),
      ]) ?? [];

    // switchToWs() is how a guard reads a WebSocket context instead of
    // an HTTP one. getClient() is the socket, getData() the payload.
    const ws = context.switchToWs();
    const client = ws.getClient<Socket>();

    if (!client.data.userId) {
      // Throw WsException, not HttpException: only WsException reaches the
      // client with its own message. Everything else becomes
      // 'Internal server error'.
      throw new WsException('Not authenticated');
    }

    const roles = (client.data.roles as string[]) ?? [];
    if (required.length > 0 && !required.some((r) => roles.includes(r))) {
      throw new WsException({
        status: 'error',
        message: 'Forbidden',
        pattern: ws.getPattern(), // the event name that was rejected
      });
    }
    return true;
  }
}

// src/admin/admin.gateway.ts
import { UseGuards } from '@nestjs/common';
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { WsRoles } from '../auth/ws-roles.decorator';
import { WsRolesGuard } from '../auth/ws-roles.guard';

@UseGuards(WsRolesGuard) // runs before every handler in this gateway
@WebSocketGateway()
export class AdminGateway {
  @WsRoles('admin')
  @SubscribeMessage('admin:purge')
  purge() {
    return { purged: true };
  }
}

The two client-side forms, and why the callback is safer

Both forms deliver the identical handshake payload, so the server code does not change. Use the object form for a token that cannot change during the page's life, and the callback form whenever tokens are short-lived, which is most apps: socket.io reconnects on its own after a dropped connection, and the object form would keep replaying the expired token.

Example · typescript
// web/src/socket.ts  (browser, socket.io-client 4.x)
import { io, Socket } from 'socket.io-client';

function accessToken(): string {
  return localStorage.getItem('access_token') ?? '';
}

// Shorthand: a plain object. Read once, when you create the socket.
export const fixed: Socket = io('https://api.example.com', {
  auth: { token: accessToken() },
});

// Longhand: a callback that supplies the same object. socket.io calls it
// when the socket connects, so a reconnect after a token refresh sends the
// fresh token rather than the stale one it was created with.
export const live: Socket = io('https://api.example.com', {
  // Skipping long-polling also means extraHeaders would be ignored here:
  // the browser's WebSocket API cannot send custom headers at all.
  transports: ['websocket'],
  auth: (cb) => cb({ token: accessToken() }),
});

live.on('connect_error', (err) => {
  // err.message is the string from next(new Error(...)) on the server,
  // err.data the object attached as err.data there.
  console.warn(err.message, (err as Error & { data?: unknown }).data);
});

live.on('auth:expired', () => {
  // Refresh, then reconnect; the callback form picks up the new token.
  live.connect();
});

Rejecting inside handleConnection instead

Use this when you need an injected service and prefer to keep everything inside the gateway class, or when you want to emit a reason the client can read as a normal event. The trade-off is real: the connection is established and billed before you reject it, and the client sees a connect followed by a disconnect rather than a clean connect_error. For pure identity checks the middleware form is cheaper.

Example · typescript
// src/events/strict.gateway.ts
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
  OnGatewayConnection,
  OnGatewayDisconnect,
  WebSocketGateway,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';

@WebSocketGateway()
export class StrictGateway implements OnGatewayConnection, OnGatewayDisconnect {
  private readonly logger = new Logger(StrictGateway.name);

  constructor(private readonly jwt: JwtService) {}

  async handleConnection(client: Socket): Promise<void> {
    // By now the client is already connected, so refusing means
    // disconnecting. Throwing here does not produce a connect_error.
    try {
      const token = String(client.handshake.auth?.token ?? '');
      const payload = await this.jwt.verifyAsync<{ sub: string }>(token);
      client.data.userId = payload.sub;
      await client.join(`user:${payload.sub}`); // personal room for targeted pushes
    } catch {
      client.emit('auth:failed', { reason: 'invalid token' });
      client.disconnect(true); // true closes the underlying connection
    }
  }

  handleDisconnect(client: Socket, reason?: string): void {
    this.logger.log(`${client.data.userId ?? 'anon'} gone: ${reason ?? '?'}`);
  }
}

Discussion

  • Be the first to comment on this lesson.