NestJS Guards & RBAC: JWT Authentication from Scratch
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:
JwtAuthGuardanswers: "Is this a valid, non-expired token?"RolesGuardanswers: "Does this user have the required role for this endpoint?"
Step 1: JWT Strategy
@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
@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:
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:
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
@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:
@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
@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:
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:
@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;
}
}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.
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