Back to Blog
NestJSAuthorizationTypeScript

NestJS RBAC in Practice: Fine-Grained Access Control with Guards and Decorators

Build a role-based access control system from scratch using NestJS Guards and custom decorators — protect any endpoint with a single line of code.

Author: ekent·Published on February 28, 2026

"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

LayerResponsibility
@Public()Mark routes that skip authentication
JwtAuthGuardVerify token, populate request.user
@Roles()Declare required roles on a route
RolesGuardRead 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