All posts
Node.js / NestJS

NestJS Guards & RBAC: JWT Authentication from Scratch

Arif Iqbal·May 15, 2026·5 min read

Most NestJS tutorials show you how to add JWT authentication. Few show you what comes next: how do you actually enforce who can do what once the token is verified?

Role-based access control (RBAC) is the answer, and in NestJS the Guard + Decorator pattern makes it clean to implement without a third-party library like CASL or AccessControl.

Here's the full implementation I use in production multi-tenant SaaS systems.

The Architecture

Request → JwtAuthGuard (verify token) → RolesGuard (check permissions) → Handler

Two guards, two concerns:

  • JwtAuthGuard answers: "Is this a valid, non-expired token?"
  • RolesGuard answers: "Does this user have the required role for this endpoint?"

Step 1: JWT Strategy

src/auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET'),
    });
  }
 
  async validate(payload: JwtPayload): Promise<AuthUser> {
    return {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
      tenantId: payload.tenantId,
    };
  }
}

The validate method return value is what gets attached to req.user. Keep it minimal — don't fetch the full user from the database on every request.

Step 2: Auth Guard

src/auth/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Allow routes decorated with @Public() to skip auth
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }
 
  handleRequest(err: Error, user: AuthUser) {
    if (err || !user) throw new UnauthorizedException();
    return user;
  }
}

Step 3: Roles

Define your roles as an enum — never as raw strings scattered across the codebase:

src/auth/roles.enum.ts
export enum Role {
  SuperAdmin = 'super_admin',
  Admin      = 'admin',
  Manager    = 'manager',
  Member     = 'member',
  Guest      = 'guest',
}

Step 4: Decorators

Two decorators: one to mark endpoints as public, one to require specific roles:

src/auth/decorators/auth.decorators.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
 
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
 
// Convenience shorthand
export const AdminOnly = () => Roles(Role.SuperAdmin, Role.Admin);

Step 5: Roles Guard

src/auth/roles.guard.ts
@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() decorator — allow any authenticated user
    if (!requiredRoles || requiredRoles.length === 0) return true;
 
    const { user } = context.switchToHttp().getRequest<{ user: AuthUser }>();
 
    // Role hierarchy: SuperAdmin can do everything
    if (user.role === Role.SuperAdmin) return true;
 
    return requiredRoles.includes(user.role);
  }
}

Step 6: Register Globally

Register both guards globally so you don't have to add them to every controller:

src/app.module.ts
@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: RolesGuard },
  ],
})
export class AppModule {}

With global registration, every endpoint is protected by default. Use @Public() to opt out.

Using It in Controllers

src/users/users.controller.ts
@Controller('users')
export class UsersController {
 
  @Get('profile')
  // Any authenticated user can view their own profile
  getProfile(@CurrentUser() user: AuthUser) {
    return this.usersService.findById(user.id);
  }
 
  @Get()
  @Roles(Role.Admin, Role.Manager)
  // Only admins and managers can list all users
  findAll() {
    return this.usersService.findAll();
  }
 
  @Delete(':id')
  @AdminOnly()
  // Shorthand decorator — admins only
  remove(@Param('id') id: string) {
    return this.usersService.remove(id);
  }
 
  @Post('register')
  @Public()
  // No auth required
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }
}

CurrentUser Decorator

Don't pass the whole request to get the user — use a param decorator:

src/auth/decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): AuthUser => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);

Multi-Tenant: Adding Tenant Isolation

In a multi-tenant system, role checks aren't enough — a user who is an Admin of Tenant A shouldn't be able to touch Tenant B's data.

Add a TenantGuard that runs after auth:

src/auth/tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const user: AuthUser = req.user;
    const tenantId = req.params.tenantId ?? req.headers['x-tenant-id'];
 
    if (!tenantId) return true; // no tenant context required for this route
 
    if (user.role === Role.SuperAdmin) return true; // super admins cross tenants
 
    return user.tenantId === tenantId;
  }
}
Scope your database queries too

The TenantGuard prevents cross-tenant HTTP access, but your service layer should also scope every database query by tenantId. Defense in depth — don't rely on a single guard as your only isolation layer.

Testing

describe('RolesGuard', () => {
  it('allows SuperAdmin regardless of required roles', () => {
    const user = { role: Role.SuperAdmin } as AuthUser;
    mockRequest.user = user;
    mockReflector.getAllAndOverride.mockReturnValue([Role.Admin]);
 
    expect(guard.canActivate(context)).toBe(true);
  });
 
  it('rejects a Member when Admin is required', () => {
    const user = { role: Role.Member } as AuthUser;
    mockRequest.user = user;
    mockReflector.getAllAndOverride.mockReturnValue([Role.Admin]);
 
    expect(guard.canActivate(context)).toBe(false);
  });
});

The full pattern — JwtAuthGuard globally, @Public() to opt out, @Roles() to require specific roles, @CurrentUser() to extract the user — covers 90% of what a production API needs. The multi-tenant layer on top of it handles the rest.


nestjsjwtauthenticationrbactypescript

Arif Iqbal

Senior Backend Engineer with 10+ years building high-traffic platforms. NestJS · Node.js · Laravel · AWS · PostgreSQL. Open to remote & relocation.

Enjoyed this post?

Get my technical deep-dives in your inbox. No spam, unsubscribe anytime.

Discussion