Implementing Custom Guards in NestJS for Role-Based Access Control
Implementing Custom Guards in NestJS for Role-Based Access Control
Every API with more than one kind of user ends up asking two questions on each request: who is this, and are they allowed to do this? Role-Based Access Control (RBAC) answers the second one by giving users roles and deciding access from those roles. In NestJS, guards are the place for that decision.
In this post we’ll build three guards (authentication, roles, and permissions), wire them up globally, add a resource ownership check, and test them. By the end you’ll know how the guards run in order and when to check a role versus a permission.
Table of Contents
- Understanding Guards in NestJS
- Setting Up the Project
- Defining Roles and Permissions
- Creating the Authentication Guard
- Building the Roles Guard
- Implementing Permission-Based Guards
- Roles or Permissions?
- Wiring the Guards Together
- Resource Ownership
- Testing Guards
Understanding Guards in NestJS
A guard is an @Injectable() class that implements CanActivate. Nest calls its canActivate() method before the route handler runs. Returning true lets the request through. Throwing an exception (or returning false) stops it.
Why Use Guards for RBAC?
- They run after middleware and before interceptors and pipes, so the user is already known and the handler hasn’t run yet.
- They get the
ExecutionContext, which gives them the request and the metadata that decorators put on the handler and controller. - You can apply them globally, per controller, or per route.
- They keep authorization rules out of your services.
- They use dependency injection like any other provider.
Setting Up the Project
npm i -g @nestjs/cli
nest new rbac-guards-demo
cd rbac-guards-demo
npm install @nestjs/config @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
This post uses NestJS 12. nest new now scaffolds an ESM project with Vitest, which is why the relative imports below end in .js and the tests use vi instead of jest.
Project Structure
src/
├── app.module.ts
├── auth/
│ ├── decorators/
│ │ ├── public.decorator.ts
│ │ ├── roles.decorator.ts
│ │ ├── permissions.decorator.ts
│ │ └── admin-only.decorator.ts
│ ├── guards/
│ │ ├── jwt-auth.guard.ts
│ │ ├── roles.guard.ts
│ │ ├── permissions.guard.ts
│ │ └── resource-owner.guard.ts
│ ├── strategies/
│ │ └── jwt.strategy.ts
│ └── auth.module.ts
├── common/
│ ├── constants/
│ │ └── role-permissions.ts
│ ├── enums/
│ │ ├── role.enum.ts
│ │ └── permission.enum.ts
│ └── interfaces/
│ └── user-request.interface.ts
└── users/
├── users.controller.ts
└── users.module.ts
Defining Roles and Permissions
Enums give us autocomplete and a compile error when someone types "admn":
// src/common/enums/role.enum.ts
export enum Role {
SUPER_ADMIN = "super_admin",
ADMIN = "admin",
MANAGER = "manager",
USER = "user",
GUEST = "guest",
}
// src/common/enums/permission.enum.ts
export enum Permission {
CREATE_USER = "create:user",
READ_USER = "read:user",
UPDATE_USER = "update:user",
DELETE_USER = "delete:user",
CREATE_POST = "create:post",
READ_POST = "read:post",
UPDATE_POST = "update:post",
DELETE_POST = "delete:post",
MANAGE_ROLES = "manage:roles",
MANAGE_PERMISSIONS = "manage:permissions",
VIEW_ANALYTICS = "view:analytics",
SYSTEM_MAINTENANCE = "system:maintenance",
}
This map says which permissions each role grants. Typing it as Record<Role, Permission[]> means that adding a new role to the enum fails the build until the role gets an entry here:
// src/common/constants/role-permissions.ts
import { Role } from "../enums/role.enum.js";
import { Permission } from "../enums/permission.enum.js";
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
[Role.SUPER_ADMIN]: Object.values(Permission),
[Role.ADMIN]: [
Permission.CREATE_USER,
Permission.READ_USER,
Permission.UPDATE_USER,
Permission.DELETE_USER,
Permission.CREATE_POST,
Permission.READ_POST,
Permission.UPDATE_POST,
Permission.DELETE_POST,
Permission.MANAGE_ROLES,
Permission.VIEW_ANALYTICS,
],
[Role.MANAGER]: [
Permission.READ_USER,
Permission.UPDATE_USER,
Permission.CREATE_POST,
Permission.READ_POST,
Permission.UPDATE_POST,
Permission.DELETE_POST,
Permission.VIEW_ANALYTICS,
],
[Role.USER]: [
Permission.READ_USER,
Permission.CREATE_POST,
Permission.READ_POST,
Permission.UPDATE_POST,
],
[Role.GUEST]: [Permission.READ_POST],
};
JwtPayload is what we put inside the token. UserPayload is what our guards read from request.user after the token is verified:
// src/common/interfaces/user-request.interface.ts
import { Request } from "express";
import { Role } from "../enums/role.enum.js";
export interface JwtPayload {
sub: number;
email: string;
roles: Role[];
}
export interface UserPayload {
id: number;
email: string;
roles: Role[];
}
export interface RequestWithUser extends Request {
user?: UserPayload;
}
user is optional because public routes never set it. That forces every guard to handle the missing case, which is exactly what we want.
Creating the Authentication Guard
Authorization needs a user to authorize, so authentication comes first. The Passport JWT strategy verifies the token’s signature and expiry, then validate() turns the payload into our UserPayload:
// src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import {
JwtPayload,
UserPayload,
} from "../../common/interfaces/user-request.interface.js";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"),
algorithms: ["HS256"],
});
}
validate(payload: JwtPayload): UserPayload {
// The signature is valid, but an older token may still carry an older shape.
if (!payload.sub || !payload.email || !Array.isArray(payload.roles)) {
throw new UnauthorizedException("Invalid token payload");
}
return { id: payload.sub, email: payload.email, roles: payload.roles };
}
}
getOrThrow is deliberate. A fallback like get("JWT_SECRET", "some-default") means a missing env var gives you a server that signs tokens with a secret anyone can read on GitHub. I’d rather the app refuse to boot.
The @Public() decorator marks routes that skip authentication:
// src/auth/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
JwtAuthGuard checks for that metadata first and only runs Passport when the route isn’t public:
// src/auth/guards/jwt-auth.guard.ts
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator.js";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return isPublic || super.canActivate(context);
}
handleRequest<TUser>(err: unknown, user: TUser | false): TUser {
if (err || !user) {
throw err instanceof Error
? err
: new UnauthorizedException("Authentication required");
}
return user;
}
}
Passport calls handleRequest with the result of the strategy. Missing, malformed, and expired tokens all end up there with user === false, so this one method produces every 401.
getAllAndOverride reads the metadata from the handler first and falls back to the controller. That’s what lets you put @Public() on a single route or on a whole controller.
Building the Roles Guard
The @Roles() decorator stores the roles a route requires:
// src/auth/decorators/roles.decorator.ts
import { SetMetadata } from "@nestjs/common";
import { Role } from "../../common/enums/role.enum.js";
export const ROLES_KEY = "roles";
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
RolesGuard reads those roles and lets the request through if the user has any of them:
// src/auth/guards/roles.guard.ts
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorators/roles.decorator.js";
import { Role } from "../../common/enums/role.enum.js";
import { RequestWithUser } from "../../common/interfaces/user-request.interface.js";
@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger(RolesGuard.name);
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles?.length) {
return true;
}
const { user } = context.switchToHttp().getRequest<RequestWithUser>();
// Only reachable when a route is both @Public() and @Roles(), which is a config mistake.
if (!user) {
throw new ForbiddenException("User not authenticated");
}
const hasRequiredRole = requiredRoles.some((role) =>
user.roles.includes(role),
);
if (!hasRequiredRole) {
this.logger.warn(
`Access denied for user ${user.id}. ` +
`Required one of: ${requiredRoles.join(", ")}. ` +
`Has: ${user.roles.join(", ") || "none"}`,
);
throw new ForbiddenException("Missing required role");
}
return true;
}
}
The log line has the detail and the response has almost none. The client doesn’t need to know which roles exist, but you’ll want them when someone files a “why do I get 403” ticket.
Implementing Permission-Based Guards
Permissions work the same way, with their own decorator:
// src/auth/decorators/permissions.decorator.ts
import { SetMetadata } from "@nestjs/common";
import { Permission } from "../../common/enums/permission.enum.js";
export const PERMISSIONS_KEY = "permissions";
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
PermissionsGuard expands the user’s roles into permissions with ROLE_PERMISSIONS, then requires all of the listed permissions:
// src/auth/guards/permissions.guard.ts
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PERMISSIONS_KEY } from "../decorators/permissions.decorator.js";
import { Permission } from "../../common/enums/permission.enum.js";
import { Role } from "../../common/enums/role.enum.js";
import { ROLE_PERMISSIONS } from "../../common/constants/role-permissions.js";
import { RequestWithUser } from "../../common/interfaces/user-request.interface.js";
@Injectable()
export class PermissionsGuard implements CanActivate {
private readonly logger = new Logger(PermissionsGuard.name);
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions?.length) {
return true;
}
const { user } = context.switchToHttp().getRequest<RequestWithUser>();
if (!user) {
throw new ForbiddenException("User not authenticated");
}
const granted = permissionsFor(user.roles);
const missing = requiredPermissions.filter((p) => !granted.has(p));
if (missing.length > 0) {
this.logger.warn(
`Permission check failed for user ${user.id}. Missing: ${missing.join(", ")}`,
);
throw new ForbiddenException("Missing required permission");
}
return true;
}
}
function permissionsFor(roles: Role[]): Set<Permission> {
return new Set(roles.flatMap((role) => ROLE_PERMISSIONS[role] ?? []));
}
The ?? [] covers a token that still carries a role you’ve since removed from the enum.
Roles or Permissions?
The two guards look almost the same, but they answer different questions.
@Roles(Role.ADMIN, Role.MANAGER) asks “is this user an admin or a manager?” It uses some, because roles are alternatives.
@RequirePermissions(Permission.UPDATE_USER, Permission.READ_USER) asks “can this user do all of these things?” It uses every, because a route that reads and updates a user needs both.
My rule of thumb is to put permissions on routes and keep roles in one place, the ROLE_PERMISSIONS map. When product decides managers can now delete posts, you change one line in that map. With @Roles() on the routes, you’d grep for every Role.ADMIN in the codebase and hope you found them all.
@Roles() still earns its place for checks that really are about identity, like “only super admins can see this debug endpoint”. Using both on one route is almost always redundant, since the permission already implies the role.
Wiring the Guards Together
We register every guard globally, so each route is authenticated unless it opts out with @Public(). That’s a safer default than remembering @UseGuards() on each new controller:
// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AuthModule } from "./auth/auth.module.js";
import { UsersModule } from "./users/users.module.js";
import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard.js";
import { RolesGuard } from "./auth/guards/roles.guard.js";
import { PermissionsGuard } from "./auth/guards/permissions.guard.js";
import { ResourceOwnerGuard } from "./auth/guards/resource-owner.guard.js";
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), AuthModule, UsersModule],
providers: [
// Global guards run in registration order. JwtAuthGuard must stay first,
// because it's the one that sets request.user for the others.
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ResourceOwnerGuard },
],
})
export class AppModule {}
The auth module reads the same JWT_SECRET as the strategy, so signing and verifying can’t drift apart:
// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { JwtStrategy } from "./strategies/jwt.strategy.js";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>("JWT_SECRET"),
signOptions: { expiresIn: "1h", algorithm: "HS256" },
}),
}),
],
providers: [JwtStrategy],
exports: [JwtModule],
})
export class AuthModule {}
Issuing tokens (a login endpoint that checks a password and calls jwtService.sign()) is its own topic. The NestJS authentication docs walk through it, and everything here works with any token that carries sub, email, and roles.
With the guards global, the controller only declares what each route needs:
// src/users/users.controller.ts
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Put,
} from "@nestjs/common";
import { Public } from "../auth/decorators/public.decorator.js";
import { Roles } from "../auth/decorators/roles.decorator.js";
import { RequirePermissions } from "../auth/decorators/permissions.decorator.js";
import { ResourceOwner } from "../auth/guards/resource-owner.guard.js";
import { Role } from "../common/enums/role.enum.js";
import { Permission } from "../common/enums/permission.enum.js";
// Validation (class-validator) is left out to keep the focus on guards.
class CreateUserDto {
email: string;
name: string;
}
@Controller("users")
export class UsersController {
@Public()
@Get("public")
getPublicInfo() {
return { message: "This is a public endpoint" };
}
// No decorator: any authenticated user.
@Get("profile")
getProfile() {
return { message: "Your profile data" };
}
@RequirePermissions(Permission.READ_USER)
@Get()
getAllUsers() {
return { message: "List of all users" };
}
@RequirePermissions(Permission.CREATE_USER)
@Post()
createUser(@Body() body: CreateUserDto) {
return { message: "User created", data: body };
}
@RequirePermissions(Permission.UPDATE_USER, Permission.READ_USER)
@ResourceOwner()
@Put(":id")
updateUser(
@Param("id", ParseIntPipe) id: number,
@Body() body: Partial<CreateUserDto>,
) {
return { message: `User ${id} updated`, data: body };
}
@RequirePermissions(Permission.DELETE_USER)
@HttpCode(HttpStatus.NO_CONTENT)
@Delete(":id")
deleteUser(@Param("id", ParseIntPipe) id: number): void {
// delete user `id`
}
@Roles(Role.SUPER_ADMIN)
@Get("debug")
getDebugInfo() {
return { message: "Internal debug info" };
}
}
When the same pair of decorators keeps showing up together, applyDecorators bundles them into one:
// src/auth/decorators/admin-only.decorator.ts
import { applyDecorators } from "@nestjs/common";
import { Roles } from "./roles.decorator.js";
import { RequirePermissions } from "./permissions.decorator.js";
import { Role } from "../../common/enums/role.enum.js";
import { Permission } from "../../common/enums/permission.enum.js";
export const AdminOnly = () =>
applyDecorators(
Roles(Role.ADMIN, Role.SUPER_ADMIN),
RequirePermissions(Permission.MANAGE_ROLES),
);
It only sets metadata. The global guards already run on every route, so adding UseGuards() here would run them twice.
Resource Ownership
Permissions say a user can update users. They don’t say which users. A regular user with UPDATE_USER should edit their own profile, not everyone’s. ResourceOwnerGuard compares the :id route param with the user’s id and lets admins through:
// src/auth/guards/resource-owner.guard.ts
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Role } from "../../common/enums/role.enum.js";
import { RequestWithUser } from "../../common/interfaces/user-request.interface.js";
export const RESOURCE_OWNER_KEY = "resourceOwner";
export const ResourceOwner = () => SetMetadata(RESOURCE_OWNER_KEY, true);
const OWNERSHIP_BYPASS_ROLES = [Role.ADMIN, Role.SUPER_ADMIN];
@Injectable()
export class ResourceOwnerGuard implements CanActivate {
private readonly logger = new Logger(ResourceOwnerGuard.name);
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const ownershipRequired = this.reflector.getAllAndOverride<boolean>(
RESOURCE_OWNER_KEY,
[context.getHandler(), context.getClass()],
);
if (!ownershipRequired) {
return true;
}
const { user, params } = context
.switchToHttp()
.getRequest<RequestWithUser>();
if (!user) {
throw new ForbiddenException("User not authenticated");
}
if (user.roles.some((role) => OWNERSHIP_BYPASS_ROLES.includes(role))) {
return true;
}
const resourceId = Number(params.id);
if (!Number.isInteger(resourceId)) {
throw new BadRequestException("Invalid resource id");
}
if (user.id !== resourceId) {
this.logger.warn(
`User ${user.id} tried to access resource ${resourceId}`,
);
throw new ForbiddenException("You can only access your own resources");
}
return true;
}
}
The guard runs before pipes, so ParseIntPipe on the controller hasn’t converted params.id yet. That’s why the guard parses it itself.
This version only works when the resource is the user. For a post owned by a user, the guard would need to load the post and compare post.authorId, which means injecting a repository into the guard. At that point I’d look at a policy library like CASL instead of growing more guards.
Testing Guards
Guards are plain classes, so the unit tests don’t need a Nest testing module. We build a real Reflector, stub the metadata lookup, and hand the guard a minimal context:
// src/auth/guards/roles.guard.spec.ts
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { RolesGuard } from "./roles.guard.js";
import { Role } from "../../common/enums/role.enum.js";
import { UserPayload } from "../../common/interfaces/user-request.interface.js";
describe("RolesGuard", () => {
const reflector = new Reflector();
const guard = new RolesGuard(reflector);
const requireRoles = (roles: Role[] | undefined) =>
vi.spyOn(reflector, "getAllAndOverride").mockReturnValue(roles);
const userWith = (...roles: Role[]): UserPayload => ({
id: 1,
email: "test@example.com",
roles,
});
// The guard only touches these three methods, so a partial context is enough.
const contextFor = (user: UserPayload | undefined) =>
({
switchToHttp: () => ({ getRequest: () => ({ user }) }),
getHandler: () => undefined,
getClass: () => undefined,
}) as unknown as ExecutionContext;
it("allows access when the route requires no roles", () => {
requireRoles(undefined);
expect(guard.canActivate(contextFor(userWith(Role.USER)))).toBe(true);
});
it("allows access when the user has the required role", () => {
requireRoles([Role.ADMIN]);
expect(guard.canActivate(contextFor(userWith(Role.ADMIN)))).toBe(true);
});
it("allows access when the user has any one of several required roles", () => {
requireRoles([Role.ADMIN, Role.MANAGER]);
expect(guard.canActivate(contextFor(userWith(Role.MANAGER)))).toBe(true);
});
it("denies access when the user lacks the required role", () => {
requireRoles([Role.ADMIN]);
expect(() => guard.canActivate(contextFor(userWith(Role.USER)))).toThrow(
ForbiddenException,
);
});
it("denies access when there is no user on the request", () => {
requireRoles([Role.USER]);
expect(() => guard.canActivate(contextFor(undefined))).toThrow(
"User not authenticated",
);
});
});
The e2e test boots the real AppModule, so all four global guards run in order. It signs tokens with the app’s own JwtService, which uses the same secret as the strategy:
// test/users.e2e-spec.ts
import { INestApplication } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { AppModule } from "../src/app.module.js";
import { Role } from "../src/common/enums/role.enum.js";
import { JwtPayload } from "../src/common/interfaces/user-request.interface.js";
describe("UsersController (e2e)", () => {
let app: INestApplication;
let jwtService: JwtService;
beforeAll(async () => {
process.env.JWT_SECRET ??= "test-secret";
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
jwtService = moduleRef.get(JwtService);
});
afterAll(async () => {
await app.close();
});
const tokenFor = (roles: Role[], sub = 1) =>
jwtService.sign({
sub,
email: "test@example.com",
roles,
} satisfies JwtPayload);
const get = (path: string, roles?: Role[]) => {
const req = request(app.getHttpServer()).get(path);
return roles ? req.set("Authorization", `Bearer ${tokenFor(roles)}`) : req;
};
describe("authentication", () => {
it("serves public routes without a token", () =>
get("/users/public").expect(200));
it("rejects protected routes without a token", () =>
get("/users/profile").expect(401));
it("serves protected routes with a valid token", () =>
get("/users/profile", [Role.USER]).expect(200));
});
describe("authorization", () => {
it("rejects a guest listing users", () =>
get("/users", [Role.GUEST]).expect(403));
it("lets a manager list users", () =>
get("/users", [Role.MANAGER]).expect(200));
it("rejects an admin on a super-admin route", () =>
get("/users/debug", [Role.ADMIN]).expect(403));
});
describe("resource ownership", () => {
const put = (id: number, roles: Role[], sub: number) =>
request(app.getHttpServer())
.put(`/users/${id}`)
.set("Authorization", `Bearer ${tokenFor(roles, sub)}`)
.send({ name: "New name" });
it("lets a manager update their own user", () =>
put(7, [Role.MANAGER], 7).expect(200));
it("rejects a manager updating someone else", () =>
put(8, [Role.MANAGER], 7).expect(403));
it("lets an admin update anyone", () =>
put(8, [Role.ADMIN], 1).expect(200));
});
});
Conclusion
Most of this post is setup. The actual authorization logic is two small guards that read metadata and compare arrays. That’s the part I like about guards: every route says what it needs in a decorator, and the rules for who has what live in ROLE_PERMISSIONS.
What to keep from this:
- Register guards globally with
APP_GUARD, and make authentication the first one. Routes opt out with@Public()instead of opting in. - Put permissions on routes and keep roles in the
ROLE_PERMISSIONSmap. Roles are alternatives (some), permissions are requirements (every). - Fail at boot when
JWT_SECRETis missing instead of falling back to a default. - Ownership checks belong in a guard only while the resource is the user. Past that, reach for a policy library.
- Unit test guards as plain classes, and keep one e2e suite that runs the whole chain.
Down the Rabbit Hole
- NestJS Guards: The official docs, including the
Reflectorand the difference betweengetandgetAllAndOverride. - NestJS Execution Context: How
ExecutionContextworks across HTTP, WebSockets, and microservices, useful once your guards need to run outside HTTP. - NestJS Authorization: The docs’ own RBAC walkthrough plus a claims-based and CASL-based version to compare with this one.
- Passport JWT recipe: The piece this post skips, issuing the tokens in the first place.
- OWASP Authorization Cheat Sheet: Deny by default, check on every request, and other rules our global guards follow.
- NIST Role-Based Access Control: Where RBAC comes from, including the formal model behind role hierarchies.
- CASL: An isomorphic permissions library for when “can this user edit this post” outgrows a guard.