How to implement Role-Based Access Control (RBAC) in NestJS?
RBAC (Role-Based Access Control) in NestJS is a pattern where a Guard reads role metadata from the handler or controller, compares it to user.roles from the request, and passes or blocks the call before your business logic runs.
Theory
TL;DR
- Analogy:
@Roles()stamps the door requirement;RolesGuardchecks the wristband on every request - Core mechanic:
SetMetadatawrites required roles onto the route;Reflectorreads them inside the Guard - Always put
AuthGuard('jwt')beforeRolesGuard- the JWT guard populatesrequest.user; without itRolesGuardcrashes - Store roles as an array in JWT (
roles: ['admin', 'moderator']), not a single string - a single string breaks multi-role users - Under 100 roles with no time or location rules: RBAC is enough. More complex policies: look at Casbin
Quick example
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), // handler metadata first
context.getClass(), // class metadata as fallback
]);
if (!requiredRoles) return true; // no decorator = public route
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}
// admin.controller.ts
@Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard) // order matters
export class AdminController {
@Get('dashboard')
@Roles(Role.Admin, Role.SuperAdmin)
getDashboard() {
return { message: 'Admin dashboard' };
}
}
// Admin request → 200 OK
// Regular user → 403 ForbiddengetAllAndOverride checks the handler first, then falls back to the class. So a @Roles(Role.Admin) on the controller class applies to every route inside it unless you override per handler.
How it works internally
NestJS loads reflect-metadata when NestFactory.create() runs. The @Roles() decorator calls SetMetadata(ROLES_KEY, roles), which writes the role array as metadata on the handler or class. When a request arrives, RolesGuard.canActivate() runs in the Express middleware chain after AuthGuard has already parsed the JWT and attached user to request. The Reflector reads the metadata, the Guard compares it to user.roles, and throws ForbiddenException on mismatch. All of this happens before your handler function is ever called.
Global guard setup
You can register RolesGuard globally so it applies everywhere without decorating individual controllers:
// app.module.ts
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}When you go global, public routes get a 403 unless you handle the no-metadata case. The if (!requiredRoles) return true line in the Guard above is what makes undecorated routes pass through. Or add a @Public() decorator that the Guard recognizes as a skip signal.
Permission-based access
Roles work for coarse-grained control. If you need "admins can write posts but not delete users", map roles to granular permissions:
export enum Permission {
ReadUsers = 'read:users',
WriteUsers = 'write:users',
DeleteUsers = 'delete:users',
ManageSettings = 'manage:settings',
}
const rolePermissions: Record<Role, Permission[]> = {
[Role.User]: [Permission.ReadUsers],
[Role.Moderator]: [Permission.ReadUsers, Permission.WriteUsers],
[Role.Admin]: [Permission.ReadUsers, Permission.WriteUsers, Permission.ManageSettings],
[Role.SuperAdmin]: Object.values(Permission), // all permissions
};
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata('permissions', permissions);
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Permission[]>(
'permissions', [context.getHandler(), context.getClass()],
);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
const userPermissions = user.roles
.flatMap((role: Role) => rolePermissions[role] ?? []);
return required.every((perm) => userPermissions.includes(perm));
}
}This pattern fits SaaS dashboards where roles overlap but permission sets differ.
Resource ownership
Sometimes role alone is not enough. A user should only edit their own posts, but an admin can edit anyone's. That needs an ownership check separate from roles:
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(private postsService: PostsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { user, params } = context.switchToHttp().getRequest();
if (user.roles.includes(Role.Admin)) return true; // admins bypass
const post = await this.postsService.findOne(params.id);
return post?.authorId === user.id;
}
}
// Chain it only when you need both checks
@Put(':id')
@UseGuards(AuthGuard('jwt'), RolesGuard, OwnershipGuard)
update(@Param('id') id: string, @Body() dto: UpdatePostDto) {
return this.postsService.update(id, dto);
}RBAC vs ABAC
| RBAC | ABAC | |
|---|---|---|
| Based on | Static roles | Attributes (role, time, IP, resource state) |
| Complexity | Low | High |
| Flexibility | Enough for most apps | Handles dynamic policies |
| Speed | Metadata lookup (microseconds) | Policy scan (milliseconds) |
| When to use | CRUD APIs, SaaS dashboards | Enterprise, compliance, AWS IAM-like |
The rule of thumb I apply on projects: if you can write all your access rules on a whiteboard in under five minutes, RBAC is enough. If you start drawing arrows between user attributes and resource states, look at Casbin.
Common mistakes
1. Using reflector.get instead of getAllAndOverride
// Wrong - misses class-level @Roles() entirely
const roles = this.reflector.get(ROLES_KEY, context.getHandler());
// Correct
const roles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);If you put @Roles(Role.Admin) on the controller class and point reflector.get only at the handler, every route in that controller is unprotected. This is the most common RBAC bug on Stack Overflow.
2. RolesGuard without AuthGuard first
// Wrong - request.user is undefined, guard throws at runtime
@UseGuards(RolesGuard)
// Correct
@UseGuards(AuthGuard('jwt'), RolesGuard)AuthGuard('jwt') runs the Passport strategy that validates the token and sets request.user. Without it, user?.roles is undefined and the guard either throws or silently passes everything.
3. Roles as a string in JWT, not an array
// Token: { role: 'admin' } ← breaks multi-role users
// Token: { roles: ['admin', 'moderator'] } ← correctWhen you later need to assign two roles to one user, a single string gives you nowhere to go. Start with an array from day one.
4. Global guard without handling public routes
A global RolesGuard with no @Roles() on /health returns 403 and breaks your deployment health check. The if (!requiredRoles) return true line in the Guard handles this automatically.
5. Fetching roles from DB on every request
At 1000 req/s that becomes a bottleneck. Put roles in the JWT payload. If roles change, use a short expiry (15 minutes) and a refresh token that pulls updated roles from DB on rotation.
Real-world usage
- NestJS official docs: Guards + Reflector as the standard RBAC pattern
@nestjs/passport+ JWT: roles in token payload, validated once, reused byRolesGuard- Prisma + NestJS: role queried once at login, stored in token, no DB hit per request
- Casbin +
nest-casbin: when RBAC combinations grow past 10-15, swap to a policy engine - Multi-tenant APIs: add
tenantIdto the role check (admin:tenant1), Guard comparesuser.tenantIdtoreq.tenantId
Follow-up questions
Q: How do you handle role hierarchy where superadmin inherits all admin permissions?
A: Extend the Guard with a roleHierarchy map: { superadmin: ['admin', 'moderator', 'user'] }. In canActivate, check hierarchy[userRole]?.includes(required) before returning false. Keep the map in memory - no DB lookups needed.
Q: What happens if a user's roles change mid-session?
A: Short-lived JWTs (15 minutes) with a refresh token that re-reads roles from DB on each rotation. Avoid long-lived tokens if you need real-time role revocation.
Q: How do you test RolesGuard with Jest?
A: Mock Reflector.getAllAndOverride to return ['admin'], mock ExecutionContext so switchToHttp().getRequest() returns { user: { roles: ['admin'] } }, then call guard.canActivate(mockContext) and assert the result. Test both match and mismatch cases. This is one of the most common NestJS senior interview questions.
Q: How do you apply RBAC across microservices?
A: Put roles in the JWT payload. Each service reads the token independently and runs its own RolesGuard. No cross-service role calls needed. When roles change, the refresh flow propagates the update across all services automatically.
Q: When does RBAC stop working?
A: When access rules depend on attributes beyond role: time of day, geographic region, resource ownership state. That is when you move to ABAC or a policy engine like Casbin. Pure RBAC handles up to roughly 100 roles cleanly.
Examples
Basic: protecting admin routes
// role.enum.ts
export enum Role {
User = 'user',
Moderator = 'moderator',
Admin = 'admin',
SuperAdmin = 'superadmin',
}
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), context.getClass(),
]);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}
// admin.controller.ts
@Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class AdminController {
@Get('dashboard')
@Roles(Role.Admin, Role.SuperAdmin)
getDashboard() { return { message: 'Admin dashboard' }; }
@Delete('users/:id')
@Roles(Role.SuperAdmin) // only superadmin can delete users
deleteUser(@Param('id') id: string) {
return this.usersService.delete(id);
}
}Admin hits /admin/dashboard - 200. Regular user hits the same route - 403. SuperAdmin calls /admin/users/42 DELETE - 200. Admin tries the same DELETE - 403.
Intermediate: e-commerce orders with role-conditional logic
// orders.controller.ts
@Controller('orders')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class OrdersController {
@Get()
@Roles(Role.User, Role.Admin)
findAll(@Req() req) {
// admins see all orders; users see only their own
return req.user.roles.includes(Role.Admin)
? this.ordersService.findAll()
: this.ordersService.findByUser(req.user.id);
}
@Patch(':id/refund')
@Roles(Role.Admin) // only admins can trigger refunds
refund(@Param('id') id: string) {
return this.ordersService.refund(id);
// Admin → { status: 'refunded' }
// Moderator → 403 Forbidden
}
}The JWT strategy attaches user.roles from the token payload. The Guard checks roles before the handler runs. Branching inside the handler on roles is a normal pattern here, not a code smell.
Advanced: hierarchical roles
// Hierarchy map (in memory, loaded once at startup)
const roleHierarchy: Record<string, string[]> = {
superadmin: ['admin', 'moderator', 'user'],
admin: ['moderator', 'user'],
moderator: ['user'],
};
@Injectable()
export class HierarchyRolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), context.getClass(),
]);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((r) => this.hasRole(user.roles, r));
}
private hasRole(userRoles: string[], required: string): boolean {
if (userRoles.includes(required)) return true;
return userRoles.some((r) => roleHierarchy[r]?.includes(required));
}
}
// superadmin passes @Roles(Role.Admin) automatically
@Get('reports')
@Roles(Role.Admin)
getReports() { ... }
// user.roles = ['superadmin'] → true
// user.roles = ['moderator'] → falseWithout the hierarchy check you list every role explicitly: @Roles(Role.Admin, Role.SuperAdmin). With it, write the minimum required role and let inheritance handle the rest.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.