"Who can do what" is a perennial challenge in enterprise systems. In one of our projects, we faced exactly this scenario: a single admin panel where regular staff could only view their own records, department managers could export reports, and only administrators could manage users. Scattering permission checks across every Controller quickly becomes a maintenance nightmare.
NestJS's Guard mechanism, combined with custom decorators, centralizes and reuses authorization logic — so protecting any endpoint takes just one declarative line.
What Is RBAC
RBAC (Role-Based Access Control) is the most common permission model:
- A user has one or more roles
- Roles determine which resources a user can access
- Permissions are assigned to roles, not to users directly
Typical roles: admin, manager, staff, guest.
How NestJS Guards Work
A Guard is a cross-cutting concern that runs after middleware but before interceptors. Every incoming request passes through Guards — returning true allows the request through, while returning false or throwing an exception blocks it.
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return !!request.user;
}
}
Guards can be scoped to an entire Controller, a single route method, or registered globally via APP_GUARD.
Step 1: Custom @Roles() Decorator
We need a way to declare "which roles may access this route" directly on the route itself. NestJS's Reflector lets us attach metadata to route handlers and read it back inside a Guard.
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
Usage is clean and readable:
@Get('reports')
@Roles('admin', 'manager')
getReports() {
return this.reportService.findAll();
}
Step 2: Implement RolesGuard
The Guard reads the role metadata attached to the route and compares it against the roles on the current user:
// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), // method-level
context.getClass(), // class-level (fallback)
]);
// No roles declared — allow through
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
// user is populated by JwtAuthGuard earlier in the chain
const { user } = context.switchToHttp().getRequest();
if (!user) return false;
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
getAllAndOverride respects priority: method-level decorators override class-level ones, so you can set a default role on the Controller and override it on specific methods.
Step 3: Guard Execution Order
In practice you typically chain two Guards: JWT verification first, then role checking. The execution order follows the registration order:
// app.module.ts
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard, // verify token, populate request.user
},
{
provide: APP_GUARD,
useClass: RolesGuard, // check roles against request.user
},
],
Each Guard has a single responsibility: JwtAuthGuard parses the token and writes user data to request.user; RolesGuard reads from request.user to check roles. Clean separation, no coupling.
Handling Public Routes
With a global JWT Guard in place, even the login endpoint gets intercepted. A @Public() decorator signals that a route should skip authentication:
// public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Check for this flag inside JwtAuthGuard:
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// ... normal JWT verification
}
Now the login endpoint only needs one annotation:
@Post('login')
@Public()
login(@Body() dto: LoginDto) { ... }
The End Result
With everything in place, access control becomes purely declarative:
@Controller('users')
export class UserController {
@Get()
@Roles('admin') // admin only
findAll() { ... }
@Get(':id')
@Roles('admin', 'manager') // admin or manager
findOne() { ... }
@Put(':id/profile')
// no @Roles — any authenticated user
updateProfile() { ... }
}
No more if (user.role !== 'admin') scattered through your business logic. Permission policy is readable at a glance.
Summary
| Layer | Responsibility |
|---|---|
@Public() | Mark routes that skip authentication |
JwtAuthGuard | Verify token, populate request.user |
@Roles() | Declare required roles on a route |
RolesGuard | Read metadata, compare against user roles |
This pattern has been running reliably across our enterprise projects. Adding a new endpoint means declaring its access policy in one line — and code reviewers can immediately tell who's allowed in. For even finer-grained control (e.g., "a user may only edit their own records"), an additional policy check in the Guard or Service layer can be layered on top without changing the core structure.
Author: ekent · ek Studio