Role-Based Access Control
Role-Based Access Control (RBAC) gates each endpoint behind a set of roles — admin, editor, user — and lets a single guard decide whether the authenticated caller holds one of them. In NestJS the idiomatic pattern is a small @Roles() decorator that attaches the required roles to a handler as metadata, plus a RolesGuard that reads that metadata with the Reflector and compares it against the user’s roles. This keeps authorization declarative: each handler states what it needs, and one reusable guard enforces it everywhere.
Modelling roles
Start with a closed set of roles. An enum keeps the values type-safe and prevents typos from silently disabling a check.
// role.enum.ts
export enum Role {
User = 'user',
Editor = 'editor',
Admin = 'admin',
}
This page assumes an earlier authentication guard has already verified the caller and attached a user object — including a roles array — to the request. RBAC is purely the authorization step that runs afterward.
The @Roles() metadata decorator
A custom decorator is just a thin wrapper around SetMetadata. It associates an array of roles with whatever it decorates — a single handler or an entire controller — under a shared key.
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
Exporting ROLES_KEY as a constant matters: the decorator that writes the metadata and the guard that reads it must agree on the exact key. A shared constant removes any chance of drift.
Reading metadata with the Reflector
The Reflector is an injectable helper that retrieves metadata set by decorators. Inside a guard you almost always want getAllAndOverride, which checks the handler first and falls back to the controller class. That precedence lets a controller declare a default role while individual handlers tighten or relax it.
// roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// No @Roles() on the route -> the endpoint is open to any authenticated user.
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}
The guard returns true when no roles are required, so unannotated routes stay reachable. When roles are required, some grants access if the user holds any one of them — the usual OR semantics for RBAC.
| Reflector method | Behaviour |
|---|---|
get(key, target) | Reads metadata from a single target only |
getAll(key, targets) | Returns an array of values, one per target — does not merge |
getAllAndOverride(key, targets) | Returns the first defined value — handler overrides controller |
getAllAndMerge(key, targets) | Merges arrays/objects from all targets together |
Use
getAllAndOverridefor roles so a method-level@Roles()cleanly replaces a controller-level default. Reach forgetAllAndMergeonly when you genuinely want the union of both levels.
Applying it to controllers
Bind the guard and annotate handlers with the roles they demand. Run an authentication guard first so request.user is populated by the time RolesGuard runs.
// articles.controller.ts
import { Controller, Delete, Get, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { RolesGuard } from './roles.guard';
import { Roles } from './roles.decorator';
import { Role } from './role.enum';
@Controller('articles')
@UseGuards(AuthGuard, RolesGuard) // order matters: authenticate, then authorize
export class ArticlesController {
@Get()
findAll() {
return [{ id: 1, title: 'Hello RBAC' }];
}
@Post()
@Roles(Role.Editor, Role.Admin)
create() {
return { id: 2, title: 'New article' };
}
@Delete(':id')
@Roles(Role.Admin)
remove() {
return { deleted: true };
}
}
GET /articles carries no @Roles(), so any authenticated user reaches it. POST needs editor or admin; DELETE is admin-only.
Registering the guard globally
For an app-wide policy, register RolesGuard with the APP_GUARD token. This keeps it inside the DI container so the Reflector is injected automatically — you never instantiate it by hand.
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
@Module({
providers: [{ provide: APP_GUARD, useClass: RolesGuard }],
})
export class AppModule {}
Seeing it in action
With a user holding ['user'], calling the admin-only delete route is rejected before the handler runs:
curl -i -X DELETE http://localhost:3000/articles/1 \
-H "Authorization: Bearer <user-token>"
Output:
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{"statusCode":403,"message":"Forbidden resource","error":"Forbidden"}
The same request with an admin token returns 200 OK and the deletion payload, because RolesGuard finds admin among the user’s roles.
Best Practices
- Define roles in an
enum, not free-form strings, so the compiler catches mismatches between the decorator and the guard. - Share a single
ROLES_KEYconstant between@Roles()and the guard — never repeat the literal string. - Run authentication before authorization: list
AuthGuardbeforeRolesGuardin@UseGuards()sorequest.userexists. - Use
getAllAndOverrideso handler-level roles take precedence over controller-level defaults. - Treat a route with no
@Roles()as open to any authenticated user, and require explicit annotations for privileged actions. - Register the guard via
APP_GUARDto keep it in the DI container and inject theReflectorautomatically. - When checks depend on resource ownership or attributes rather than a fixed role list, graduate to attribute-based access control.