Guards, Pipes and Interceptors
Guards for authorization, Pipes for validation with ValidationPipe and DTOs, and Interceptors for wrapping request handling.
Where these fit in the request lifecycle
Before diving in, it helps to see where each piece runs, since the names alone don't make the order obvious:
Request → Middleware → Guards → Interceptors (before) → Pipes → Route handler
│
Response ← Exception filters ← Interceptors (after) ←────────────────┘
- Guards decide whether a request is allowed to reach the route handler at all.
- Pipes transform and validate the data that reaches the handler's parameters.
- Interceptors wrap around the handler, able to run logic both before and after it executes.
Guards — authorization
A guard is a class implementing CanActivate. Its canActivate() method returns (or resolves to) true to allow the request through, or throws/returns false to reject it — commonly used to check authentication tokens or user roles.
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader: string | undefined = request.headers['authorization'];
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or malformed bearer token');
}
const token = authHeader.slice('Bearer '.length);
// In a real app: verify the token (e.g. with @nestjs/jwt) and attach the user to the request
request.user = { id: 1, token };
return true;
}
}
Apply it to a single route, a whole controller, or globally:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
@Controller('admin')
@UseGuards(AuthGuard) // every route in this controller now requires a valid bearer token
export class AdminController {
@Get('stats')
stats() {
return { activeUsers: 128 };
}
}
Pipes — validation and transformation
A pipe runs on incoming data before it reaches the route handler — either transforming it (e.g. a string route param into a number) or validating it and throwing if it's invalid. Nest's built-in ValidationPipe, combined with a DTO (Data Transfer Object) class decorated with class-validator decorators, is the standard way to validate request bodies:
npm install class-validator class-transformer
// create-user.dto.ts
import { IsString, IsEmail, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
}
// users.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CreateUserDto } from './create-user.dto';
@Controller('users')
export class UsersController {
@Post()
create(@Body() createUserDto: CreateUserDto) {
return { id: 1, ...createUserDto };
}
}
ValidationPipe doesn't do anything on its own until it's registered — typically once, globally, in main.ts:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(3000);
}
bootstrap();
With this registered, a POST /users body missing email, or with an invalid email format, is rejected automatically with a 400 Bad Request and a descriptive error message — the handler body never even runs. The whitelist: true option additionally strips any properties from the request body that aren't declared on the DTO.
Interceptors — wrapping the handler
An interceptor implements NestInterceptor, and its intercept() method gets a CallHandler representing the route handler — calling next.handle() runs it, and returns an RxJS Observable you can transform, time, or log around:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
console.log(`${request.method} ${request.url} — ${Date.now() - start}ms`);
}),
);
}
}
import { Controller, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor';
@Controller('users')
@UseInterceptors(LoggingInterceptor)
export class UsersController {
// ...
}
Interceptors are also the typical place to reshape every response consistently — for example, wrapping every successful payload in { data: ... } — without repeating that logic in every handler.
Common mistakes
- Adding
@IsEmail()and friends to a DTO but forgetting to actually registerValidationPipe— the decorators are inert metadata until aValidationPipereads them. - Returning
falsefrom a guard when you meant to reject with a specific error —falseproduces a generic403 Forbidden; throwingUnauthorizedException/ForbiddenExceptionyourself gives a more useful response body. - Forgetting that guards run before interceptors and pipes — putting authorization logic in an interceptor or pipe means unauthenticated requests may still touch dependencies (like an ORM call inside an interceptor) before authorization is checked.