WebSocket Guards, Pipes & Filters
The same enhancers you bind to HTTP controllers — guards, pipes, and exception filters — also work inside WebSocket gateways, but with a different transport context and a different exception type. Guards decide whether a socket may invoke a handler, pipes transform and validate the incoming payload, and filters translate thrown errors into structured messages the client can read. This page shows how to reuse those building blocks in the WS world, validate message DTOs, throw and catch WsException, and authenticate a connecting socket from its handshake.
How the WS execution context differs
Enhancers receive an ExecutionContext, but for WebSocket handlers the underlying arguments are not (request, response) — they are (client, data). To read them in a transport-agnostic guard or interceptor you switch to the WS context first.
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Socket } from 'socket.io';
@Injectable()
export class WsThrottleGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const client: Socket = context.switchToWs().getClient<Socket>();
const data = context.switchToWs().getData<unknown>();
return Boolean(client) && data !== undefined;
}
}
switchToWs() exposes getClient() (the socket) and getData() (the message payload). Calling switchToHttp() inside a gateway would yield undefined, so always branch on the transport when an enhancer is shared across both.
Validating payloads with pipes
Pipes run before the handler and can both transform and validate the @MessageBody() argument. The built-in ValidationPipe works exactly as it does for HTTP: pair it with a class-validator DTO and Nest rejects malformed messages automatically. Bind it per-handler with @UsePipes, or per-parameter for finer control.
import { IsString, MaxLength, MinLength } from 'class-validator';
export class SendMessageDto {
@IsString()
@MinLength(1)
room: string;
@IsString()
@MaxLength(500)
text: string;
}
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
} from '@nestjs/websockets';
import { UsePipes, ValidationPipe } from '@nestjs/common';
import { SendMessageDto } from './dto/send-message.dto';
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway {
@UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
@SubscribeMessage('message')
handleMessage(@MessageBody() body: SendMessageDto): SendMessageDto {
return { room: body.room, text: body.text.trim() };
}
}
When validation fails, the pipe throws — and because we are in a WS context, Nest converts it into a WsException rather than an HTTP error.
Pipes need the runtime metadata
class-validatorrelies on. EnableemitDecoratorMetadataandexperimentalDecoratorsintsconfig.json, and callapp.useGlobalPipes(new ValidationPipe())inmain.tsif you want validation applied to every gateway without repeating@UsePipes.
Throwing and catching WsException
HTTP handlers throw HttpException; WebSocket handlers throw WsException. It is a lightweight error that carries either a string or an object payload. The default WS exception filter serializes it into an exception event sent back to the offending client.
import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets';
import { WsException } from '@nestjs/websockets';
@WebSocketGateway()
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(@MessageBody() body: { text: string }) {
if (body.text.length > 500) {
throw new WsException('Message too long');
}
return { ok: true };
}
}
Output:
client → emit('message', { text: '...600 chars...' })
client ← exception { status: 'error', message: 'Message too long' }
Customizing errors with a filter
To control the shape of that exception event — adding an error code, timestamp, or the original event name — write a filter that extends BaseWsExceptionFilter. Catching WsException lets you handle validation and business errors uniformly, while still delegating unknown errors to the base implementation.
import { ArgumentsHost, Catch } from '@nestjs/common';
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
@Catch(WsException)
export class WsExceptionFilter extends BaseWsExceptionFilter {
catch(exception: WsException, host: ArgumentsHost) {
const client = host.switchToWs().getClient<Socket>();
const error = exception.getError();
const message = typeof error === 'string' ? error : (error as any).message;
client.emit('exception', {
status: 'error',
code: 'WS_ERROR',
message,
timestamp: new Date().toISOString(),
});
}
}
Bind it with @UseFilters(new WsExceptionFilter()) on a handler or gateway, or globally via app.useGlobalFilters(new WsExceptionFilter()).
Authenticating the socket with a guard
Sockets authenticate once, typically from a token passed in the handshake (auth payload or query string). A guard reads that token, verifies it, and attaches the user to the socket so later handlers can trust it. Throw a WsException to reject unauthorized messages.
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
@Injectable()
export class WsAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const client: Socket = context.switchToWs().getClient<Socket>();
const token =
client.handshake.auth?.token ??
(client.handshake.query?.token as string | undefined);
if (!token) {
throw new WsException('Missing auth token');
}
try {
const payload = this.jwt.verify(token);
client.data.user = payload;
return true;
} catch {
throw new WsException('Invalid auth token');
}
}
}
import { UseGuards } from '@nestjs/common';
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { WsAuthGuard } from './ws-auth.guard';
@UseGuards(WsAuthGuard)
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway {
@SubscribeMessage('message')
handleMessage() {
return { ok: true };
}
}
Storing the user on client.data makes it available to every subsequent handler without re-verifying the token.
Enhancer reference
| Enhancer | Interface / base | When it runs | Reads context via |
|---|---|---|---|
| Guard | CanActivate | Before the handler | switchToWs().getClient() |
| Pipe | PipeTransform | On each @MessageBody() arg | The value + ArgumentMetadata |
| Filter | BaseWsExceptionFilter | On a thrown exception | switchToWs().getClient() |
| Interceptor | NestInterceptor | Around the handler | switchToWs().getData() |
Guards run once per message, not per connection. For per-connection checks (rejecting a socket before any message), authenticate inside the gateway’s
handleConnectionlifecycle hook and callclient.disconnect().
Best Practices
- Always call
context.switchToWs()(neverswitchToHttp()) inside WS enhancers, and guard against shared enhancers being reused across both transports. - Throw
WsException— notHttpException— from gateway code so the WS exception layer can serialize it correctly. - Apply a global
ValidationPipewithwhitelist: trueso every gateway rejects unknown or malformed fields by default. - Verify the token in a guard and stash the decoded user on
client.dataso downstream handlers stay stateless and trusted. - Extend
BaseWsExceptionFilterrather than reimplementing it, so unexpected errors still get sane default handling. - Reject unauthenticated sockets in
handleConnectionfor connection-level security, and use guards for per-message authorization.