Guards, explained
Guards answer one question: can this request continue? They sit at the boundary between your client and your route handler, making a binary decision that either allows the request through or stops it in its tracks. If you've built any production system with authentication, authorization, or role-based access control, you've felt the need for guards—today we'll build them properly.
What guards actually do
A guard is middleware with a single responsibility: evaluate whether a request is allowed to proceed. Unlike middleware, guards have access to the execution context, which means they can read route metadata, extract information from decorators, and make decisions based on which specific handler they're protecting.
The lifecycle is simple. A request arrives, passes through global middleware, hits your routing layer, and then encounters any guards attached to that route. Each guard's canActivate method runs. If all return true, execution continues to the handler. If any returns false or throws an exception, the request stops.
I use guards for authentication checks, permission validation, rate limiting decisions, and feature flags. They're lighter than middleware for route-specific logic and cleaner than duplicating validation inside every handler.
Implement canActivate and return true or false
Every guard implements the CanActivate interface, which requires a single method: canActivate. This method receives the ExecutionContext and returns a boolean or a Promise that resolves to a boolean.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class SimpleAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
return !!token; // True if token exists, false otherwise
}
}Here's what I'm doing: I extract the HTTP request from the execution context, check if an authorization header exists, and return true or false. That's the foundation. The request either continues to your handler or it doesn't.
For async operations—validating a token against a database or checking a third-party service—return a Promise:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class DatabaseAuthGuard implements CanActivate {
constructor(private userService: UserService) {}
async canActivate(context: ExecutionContext): Promise {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
if (!token) return false;
try {
const user = await this.userService.validateToken(token);
request.user = user; // Attach user to request for later use
return true;
} catch (error) {
return false;
}
}
}Notice I'm attaching the validated user to the request object. This is a common pattern—guards don't just make decisions, they often enrich the request with data your handler will need.
Read metadata with Reflector
The real power of guards emerges when you combine them with decorators and metadata. Reflector lets you read metadata attached to your handlers and controllers, enabling flexible, declarative permission logic.
Let's say you want certain routes to require specific roles. You'd create a custom decorator:
import { SetMetadata } from '@nestjs/common';
export const Roles = (roles: string[]) => SetMetadata('roles', roles);Then use it on your handlers:
@Get('admin-panel')
@Roles(['admin', 'moderator'])
getAdminPanel() {
return { message: 'Admin area' };
}Now build a guard that reads this metadata:
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get('roles', context.getHandler());
// If no roles metadata, allow access
if (!requiredRoles) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) throw new ForbiddenException('User not found');
const hasRole = requiredRoles.some(role => user.roles.includes(role));
return hasRole;
}
}The Reflector queries metadata from the handler. If the @Roles decorator was applied, we get the array. We then check if the authenticated user has any of those roles. This approach scales well—add new role requirements without touching the guard logic.
You can also read class-level metadata to set defaults:
const requiredRoles =
this.reflector.get('roles', context.getHandler()) ||
this.reflector.get('roles', context.getClass()) ||
[];Throw ForbiddenException for clearer errors
Returning false works, but it's opaque to the client. Throwing an exception gives you control over the HTTP response and error message, which matters in production.
When a guard returns false, NestJS sends a 403 Forbidden by default. When you throw an exception, you control the status code and payload:
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class DetailedAuthGuard implements CanActivate {
constructor(private userService: UserService) {}
async canActivate(context: ExecutionContext): Promise {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const user = await this.userService.validateToken(token);
request.user = user;
return true;
} catch (error) {
throw new UnauthorizedException('Invalid or expired token');
}
}
}Now the client receives a 401 Unauthorized with a descriptive message instead of a bare 403. For permission denials after authentication, use ForbiddenException:
if (!hasRole) {
throw new ForbiddenException(
`User requires one of: ${requiredRoles.join(', ')}`
);
}This distinction helps clients handle errors appropriately. A 401 means "authenticate and retry." A 403 means "you're authenticated but lack permission."
Apply globally, per controller or per route
Guards can be attached at three scopes, each with different tradeoffs.
Global guards
Register in your main module to apply to every route:
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AppModule {}I use global guards for cross-cutting concerns like authentication or audit logging. Every request should validate a token before reaching any handler. But be careful—you can't apply metadata-based logic uniformly across all routes without conditions.
Controller-level guards
Apply to all routes in a controller using the @UseGuards decorator:
import { UseGuards, Controller, Get } from '@nestjs/common';
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
@Get()
getAll() {
return [];
}
@Get(':id')
getOne(@Param('id') id: string) {
return { id };
}
}Both routes now require the AuthGuard. This is useful when an entire feature set requires the same permission level.
Route-level guards
Apply only to specific handlers:
@Controller('users')
export class UsersController {
@Get('public')
getPublic() {
return { data: 'everyone can see this' };
}
@Get('admin')
@UseGuards(AdminGuard)
getAdmin() {
return { data: 'admin only' };
}
}Route-level guards are the most common pattern in my projects. You protect specific sensitive endpoints while leaving public ones open. You can also combine multiple guards:
@Post()
@UseGuards(AuthGuard, RolesGuard)
@Roles(['admin'])
createUser(@Body() dto: CreateUserDto) {
return { created: true };
}Guards execute in order. If AuthGuard fails, RolesGuard never runs. This lets you layer checks efficiently.
Common mistakes
- Returning false instead of throwing exceptions: False works, but you lose the ability to provide context to the client. Always throw UnauthorizedException or ForbiddenException instead. It's clearer and gives you error details in logs.
- Forgetting to attach enriched data to the request: Guards validate access but should also attach relevant data—the authenticated user, decoded claims, feature flags—so your handler doesn't duplicate the work.
- Creating guards that are too broad: A guard that checks both authentication and authorization in one class becomes hard to reuse and test. Split them: one guard for "are you logged in," another for "do you have this role."
- Not handling async operations correctly: If your guard does I/O (database lookup, external API call), always use async/await and handle errors properly. A guard that throws an unhandled promise rejection will crash your request.
- Applying global guards without conditional logic: If every route needs different treatment, global guards can become messy. Use controller or route-level guards instead, or build complex conditional logic into your global guard using Reflector.
Key takeaways
- Guards answer a single question: can this request continue? They return true or false, or throw an exception to control the HTTP response.
- Use the Reflector to read metadata from decorators, enabling flexible, declarative permission systems without hardcoding logic in your guard.
- Throw UnauthorizedException for missing/invalid authentication and ForbiddenException for failed authorization checks. This distinction helps clients respond appropriately.
- Attach enriched data (user, claims, flags) to the request object inside your guard so handlers don't repeat the validation work.
- Apply guards at the scope that matches your need: global for cross-cutting concerns, controller-level for feature-wide protection, route-level for fine-grained control.
- Keep guards focused and composable. A guard that does one thing well is easier to test, reuse, and reason about than a monolithic permission system.






Comments
Loading comments…