Authorization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2, published 2026-09-30), Node.js 22 and 24 LTS,
TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, with @nestjs/authorization 0.0.1 (first published 2026-09-29, so
pre-1.0), @casl/ability 7.0.1, @nestjs/graphql 14 with Apollo Server 5 and @nestjs/jwt 12, all checked on
2026-10-02. The examples use the Nest 12 CLI project layout with Vitest and supertest; the REST examples
behave the same on Express and Fastify, and the GraphQL example uses Express with Apollo. Authentication answers who is
calling? and is covered in Authentication. Authorization answers what may
this caller do, and to which record? In the Bookshelf application that grows across this section it is the difference
between a reader who can only read books, an editor who can add them, an author who can edit only her own review, and a
moderator who can delete anyone’s review except one under legal hold. This page walks through five ways to express those
rules, from the simplest to the most expressive, and says when each one is the right tool.
The layers of an authorization decision
A single rule such as "authors edit their own reviews" cannot be answered by looking at the route alone: it needs the caller and the review. NestJS therefore offers decisions at three depths, and a mature application usually mixes them:
-
Route level: may this caller reach this endpoint at all? A guard answers it before the handler runs, using only the caller and the metadata that decorators attached to the handler (roles, required claims, a policy ability).
-
Record level: may this caller act on this particular record? The record exists only after a query, so the decision belongs in a service, after the load. Guards cannot answer it, because they run before the handler loaded anything.
-
Field level: may this caller see this property of a record they may otherwise read? This is a serialization or GraphQL concern; see Field-level authorization in GraphQL and CASL: the established alternative.
The diagram shows the two places where a policy is consulted. Guards run after middleware and before interceptors and pipes (see Request Lifecycle), so a route-level denial costs nothing but the check, while a record-level denial happens inside the handler after the data was loaded.
401 or 403
The status code carries information. 401 Unauthorized means we do not know who you are; authenticate and try again,
so a client may respond by showing a sign-in screen. 403 Forbidden means we know who you are, and the answer is no,
which a sign-in screen cannot fix. Every example below follows that rule: a guest gets a 401, a signed-in caller without
permission a 403. Returning 404 instead of 403 for records whose very existence is sensitive (other tenants' documents)
is a deliberate third option, shown in Claims and attribute-based access.
Role-based access control (RBAC)
Role-based access control attaches roles to users and required roles to routes, and lets a guard compare the two. It is
the right starting point for most applications: it is easy to explain, easy to audit and needs no extra package. Its
limit is that a role is a property of the caller only, so it cannot say "only the author" (that needs the record,
see Policies with @nestjs/authorization) and it multiplies when every nuance becomes a role (editor-eu, editor-trial, …).
The metadata side uses Reflector.createDecorator(), the current way to define route metadata. It returns a typed
decorator, so @Roles(['editor']) is checked by the compiler, and the same object is what the guard reads back:
// rbac/roles.decorator.ts
import { Reflector } from '@nestjs/core';
import type { Role } from '../auth/user.js';
/** `@Roles(['editor'])`: the caller needs at least one of these roles. */
export const Roles = Reflector.createDecorator<Role[]>();
/** `@Public()`: no sign-in needed. The stored value is `true`. */
export const Public = Reflector.createDecorator<void, boolean>({ transform: () => true });
The guard decides. It reads the metadata with getAllAndOverride(), which looks at the handler first and the class
second and returns the first value it finds, so a method-level @Roles() replaces the class default. A role hierarchy
(an admin may do everything a moderator may) is a small lookup table; without it, every route would have to list every
role that may enter. The guard distinguishes the two failures from 401 or 403: no user at all is a 401, a user
without a matching role a 403.
// rbac/roles.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedRequest, Role } from '../auth/user.js';
import { Public, Roles } from './roles.decorator.js';
/** A role grants itself and every role below it. */
const GRANTS: Record<Role, Role[]> = {
reader: ['reader'],
editor: ['editor', 'reader'],
moderator: ['moderator', 'editor', 'reader'],
admin: ['admin', 'moderator', 'editor', 'reader'],
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const targets = [context.getHandler(), context.getClass()];
if (this.reflector.getAllAndOverride(Public, targets)) {
return true;
}
const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!user) {
throw new UnauthorizedException(); // 401: who are you?
}
const required = this.reflector.getAllAndOverride(Roles, targets);
if (!required) {
return true; // signed in is enough
}
const granted = new Set(user.roles.flatMap((role) => GRANTS[role]));
if (!required.some((role) => granted.has(role))) {
throw new ForbiddenException(); // 403: we know you, and the answer is no
}
return true;
}
}
A controller declares what it needs. The class-level @Roles(['reader']) is the default for every method, and
@Public() opts a single route out. This is the secure-by-default shape: a method that forgets to declare anything is
still protected, because the guard requires a signed-in caller unless the route is explicitly public.
// rbac/books.controller.ts
import { Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
import { Public, Roles } from './roles.decorator.js';
@Roles(['reader']) // class-level default: any signed-in reader or above
@Controller('books')
export class BooksController {
@Public()
@Get()
findAll() {
return [{ id: '1', title: 'Dune' }];
}
@Get(':id')
findOne(@Param('id') id: string) {
return { id, title: 'Dune' };
}
@Roles(['editor']) // the handler overrides the class default
@Post()
create() {
return { id: '2', title: 'Emma' };
}
@Roles(['admin'])
@Delete(':id')
@HttpCode(204)
remove() {}
}
Source: NestJS: Guards - Binding guards
The guard is registered globally with APP_GUARD, so no route can forget to attach it. Registration order is execution
order, which matters because the roles guard needs request.user. In the examples a small authentication guard
(AuthenticateGuard, in a module imported before the roles module) verifies a JWT with JwtService.verifyAsync() from
@nestjs/jwt, reads the claims into request.user and lets guests through unauthenticated; the signing secret comes
from ConfigService, never from source code (generate one with openssl rand -hex 32). The authentication mechanisms
themselves are the subject of Authentication.
// rbac/rbac.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { BooksController } from './books.controller.js';
import { RolesGuard } from './roles.guard.js';
@Module({
controllers: [BooksController],
providers: [{ provide: APP_GUARD, useClass: RolesGuard }],
})
export class RbacModule {}
// rbac/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpAuthModule } from '../auth/http-auth.module.js';
import { RbacModule } from './rbac.module.js';
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
HttpAuthModule, // its guard loads request.user, so it must be imported before the module whose guard reads it
RbacModule,
],
})
export class AppModule {}
Source: NestJS: Guards - Binding guards
Calling the API with tokens issued by the sign-in flow ($READER_TOKEN, $EDITOR_TOKEN and $ADMIN_TOKEN are
placeholders for them):
curl -i localhost:3000/books/1 # guest on a protected route
curl -i localhost:3000/books -X POST -H "Authorization: Bearer $READER_TOKEN" # reader, route needs editor
curl -i localhost:3000/books/1 -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" # admin deletes
HTTP/1.1 401 Unauthorized
{"message":"Unauthorized","statusCode":401}
HTTP/1.1 403 Forbidden
{"message":"Forbidden","statusCode":403}
HTTP/1.1 204 No Content
The same guard and tests work unchanged on Fastify. getAllAndMerge() is the alternative to getAllAndOverride() when
class-level and method-level values should be combined (for example, permissions that add up); see
Guards and
Execution Context, Reflector & Discovery. For a
hand-rolled decorator composed of several decorators, see Custom Decorators.
Policies with @nestjs/authorization
@nestjs/authorization is a first-party package introduced in the September 2026 documentation restructure. It is
pre-1.0 (0.0.1 on 2026-10-02): its API can still change between minor releases, so pin its version, wrap it in your
own thin layer if many modules depend on it, and read its release notes before upgrading. Its premise is that
permissions are policies: ordinary provider classes whose methods answer "may this user do this?". Because a policy
is a provider, it injects services; because every check is a typed method call, a misspelled ability does not compile;
and because policies take plain objects, they work with whatever Prisma, Drizzle, TypeORM or a raw query returned.
npm install @nestjs/authorization
Registering the module
AuthorizationModule.forRoot() registers the module globally and does three things: it provides
AuthorizationService, it registers AuthorizationGuard as a global guard that enforces the @Can() decorator, and it
turns a denial reported by a service into a 401 or a 403. Handlers without @Can() pass through the guard untouched.
The import order matters. Nest runs global guards in the order it scans the modules, and the authorization guard
reads request.user, so the module that authenticates has to be imported first. Imported the other way round, every
@Can() check would see a guest. The documentation says that with @nestjs/authentication the module detects the mistake and stops the
application at startup. With a custom authentication guard, like the one in these examples, it can only go by the
guard’s class name; it logs an error naming both guards and keeps starting, so an end-to-end
test that signs in and calls a @Can() route is the real safety net.
// policies/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthorizationModule } from '@nestjs/authorization';
import { HttpAuthModule } from '../auth/http-auth.module.js';
import { ReviewsModule } from './policies.module.js';
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
HttpAuthModule, // authentication first: its guard sets request.user
AuthorizationModule.forRoot(), // then authorization: its global guard enforces @Can()
ReviewsModule,
],
})
export class AppModule {}
By default the guard finds the user on request.user for HTTP, on the GraphQL context’s req.user, on the WebSocket
client’s user or data.user, and on the transport context’s user for microservices; the getUser option replaces
that lookup if your authentication stores the user elsewhere. null, undefined and false all mean "guest".
Writing a policy: route-level and record-level abilities
A policy is a class decorated with @Policy(). Each method whose first parameter is the user and that returns
boolean or Promise<boolean> is an ability, and only a literal true allows: any other value, even a truthy one,
denies. The type of the user parameter is a decision. AuthUser | null means the ability also runs for guests, who
arrive as null; AuthUser means every caller must be signed in, and the compiler then makes callers rule out a missing
user before they ask.
create), record-level abilities (update, delete) and a before() hook// policies/review.policy.ts
import { Policy, type Ability, type PolicyBefore } from '@nestjs/authorization';
import type { AuthUser } from '../auth/user.js';
import type { Review } from './review.js';
import { SuspensionsService } from './suspensions.service.js';
@Policy()
export class ReviewPolicy implements PolicyBefore<AuthUser, Ability<ReviewPolicy>> {
// A policy is a provider: it can inject services through its constructor.
constructor(private readonly suspensions: SuspensionsService) {}
// Runs ahead of every ability. `true` allows, `false` denies, nothing defers to the ability.
before(user: AuthUser | null, ability: Ability<ReviewPolicy>) {
if (ability === 'delete') {
return; // legal holds bind admins too, so delete() decides
}
if (user?.roles.includes('admin')) {
return true;
}
}
// Route-level ability: no record, and `null` (a guest) is a valid caller.
async create(user: AuthUser | null) {
return !!user && !(await this.suspensions.isSuspended(user.id));
}
// Record-level abilities take the review as a second argument.
update(user: AuthUser, review: Review) {
return review.authorId === user.id;
}
delete(user: AuthUser, review: Review) {
if (review.legalHold) {
return false;
}
const privileged = user.roles.includes('admin') || user.roles.includes('moderator');
return privileged || review.authorId === user.id;
}
}
create has no record to look at: it depends on the caller alone, so it can be enforced by the guard with @Can()
before the handler runs. update and delete depend on the review, which exists only after the service loaded it, so
they are called from the service. @Can() accepts only abilities that can be called with the user alone and whose user
parameter accepts null, and it is type-checked: @Can(ReviewPolicy, 'creat') does not compile, and neither does
@Can(ReviewPolicy, 'update'), because update needs a review.
// policies/reviews.controller.ts
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
import { Can } from '@nestjs/authorization';
import type { AuthUser } from '../auth/user.js';
import { CurrentUser } from './current-user.decorator.js';
import { ReviewPolicy } from './review.policy.js';
import { ReviewsService } from './reviews.service.js';
@Controller('reviews')
export class ReviewsController {
constructor(private readonly reviews: ReviewsService) {}
@Get()
findAll(@CurrentUser() user: AuthUser) {
return this.reviews.findAll(user);
}
// Route-level check: the global AuthorizationGuard calls ReviewPolicy.create(user) before the handler.
@Post()
@Can(ReviewPolicy, 'create')
create(@CurrentUser() user: AuthUser, @Body('text') text: string) {
return this.reviews.create(user, text);
}
@Patch(':id')
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('text') text: string) {
return this.reviews.update(user, id, text);
}
@Delete(':id')
@HttpCode(204)
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.reviews.remove(user, id);
}
}
@CurrentUser() in the controller is a small createParamDecorator() that returns request.user; see
Custom Decorators.
The service loads the record and then calls authorize(), which throws when the ability denies, or can(), which
resolves to a boolean. Putting the record check in the service rather than the controller means every caller of the
service gets the same rules: a REST controller, a GraphQL resolver or a queue worker.
// policies/reviews.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { AuthorizationService } from '@nestjs/authorization';
import type { AuthUser } from '../auth/user.js';
import type { Review } from './review.js';
import { ReviewPolicy } from './review.policy.js';
export type ReviewView = Review & { can: { update: boolean; delete: boolean } };
@Injectable()
export class ReviewsService {
private readonly reviews: Review[] = [
{ id: 'r1', bookId: 'b1', authorId: 'alice', text: 'Great', legalHold: false },
{ id: 'r2', bookId: 'b1', authorId: 'bob', text: 'Dull', legalHold: false },
{ id: 'r3', bookId: 'b1', authorId: 'bob', text: 'Disputed', legalHold: true },
];
constructor(private readonly authorization: AuthorizationService) {}
/** `can()` never throws: it answers true or false, which suits shaping a response. */
async findAll(user: AuthUser): Promise<ReviewView[]> {
return Promise.all(
this.reviews.map(async (review) => ({
...review,
can: {
update: await this.authorization.can(ReviewPolicy, 'update', user, review),
delete: await this.authorization.can(ReviewPolicy, 'delete', user, review),
},
})),
);
}
create(user: AuthUser, text: string): Review {
const review = { id: `r${this.reviews.length + 1}`, bookId: 'b1', authorId: user.id, text, legalHold: false };
this.reviews.push(review);
return review;
}
async update(user: AuthUser, id: string, text: string) {
const review = this.find(id);
// authorize() throws an AuthorizationError that the module maps to 401 or 403.
await this.authorization.authorize(ReviewPolicy, 'update', user, review);
review.text = text;
return review;
}
async remove(user: AuthUser, id: string) {
const review = this.find(id);
await this.authorization.authorize(ReviewPolicy, 'delete', user, review);
this.reviews.splice(this.reviews.indexOf(review), 1);
}
private find(id: string) {
const review = this.reviews.find((candidate) => candidate.id === id);
if (!review) {
throw new NotFoundException();
}
return review;
}
}
Register the policy as a provider of its feature module. @Policy() also makes the class injectable, and the
authorization module discovers every policy provider in the application wherever it is registered; alternatively,
list policies in the policies option of forRoot(). A @Can() that points at a policy nobody registered stops the
application at startup, and the error names the handler.
// policies/policies.module.ts
import { Module } from '@nestjs/common';
import { ReviewPolicy } from './review.policy.js';
import { ReviewsController } from './reviews.controller.js';
import { ReviewsService } from './reviews.service.js';
import { SuspensionsService } from './suspensions.service.js';
@Module({
controllers: [ReviewsController],
// Policies are ordinary providers: the authorization module discovers them wherever they are registered.
providers: [ReviewsService, ReviewPolicy, SuspensionsService],
})
export class ReviewsModule {}
What the caller sees: 401, 403 and the error object
Authorization never decides whether a route needs a user; that is authentication’s job. When a policy denies a guest, the package answers 401 rather than 403, because signing in might change the answer. The cases:
| Caller | Request | Status | Why |
|---|---|---|---|
Guest |
|
401 |
|
Suspended user |
|
403 |
|
Signed-in user |
|
403 |
|
Guest |
|
401 |
|
authorize() itself throws an AuthorizationError whose reason is unauthenticated or forbidden, whose status is
the matching 401 or 403, and which also carries the policy and ability names. It is deliberately not an HTTP
exception, because the same service also runs in queue workers and WebSocket gateways. When the error leaves a handler,
AuthorizationModule converts it into UnauthorizedException or ForbiddenException, into a GraphQL error whose
extensions.code is UNAUTHENTICATED or FORBIDDEN, or into a WsException or RpcException; the response body never
names the policy or the ability. An AuthorizationError thrown outside a handler, for example from your own middleware
or guard, is not converted and becomes a 500; use can() there and respond yourself.
before(): letting one role through
Rather than repeating || user.roles.includes('admin') in every ability, a policy may declare a before() hook that
runs ahead of every ability of that policy, for route checks and service checks alike. It receives the user, the ability
name and the ability’s arguments. Return true to allow, false to deny, or nothing to let the ability decide. Guests
reach before() too, so its user parameter must accept null.
The hook skips every ability, including rules that should bind everyone. In Bookshelf a review under legal hold can
never be deleted, not even by an admin, so ReviewPolicy implements PolicyBefore<AuthUser, Ability<ReviewPolicy>>,
types the second parameter as the policy’s ability names (a misspelled exclusion does not compile) and defers on
delete:
@Policy()
export class ReviewPolicy implements PolicyBefore<AuthUser, Ability<ReviewPolicy>> {
// Runs ahead of every ability. `true` allows, `false` denies, nothing defers to the ability.
before(user: AuthUser | null, ability: Ability<ReviewPolicy>) {
if (ability === 'delete') {
return; // legal holds bind admins too, so delete() decides
}
if (user?.roles.includes('admin')) {
return true;
}
}
// ...abilities
}
Calling an ability directly in a unit test skips before(), because the hook is just another method. To test the hook
and the ability together, go through AuthorizationService; to test the 401/403 mapping too, go through HTTP.
Dependency injection in policies
A policy is a provider, so ReviewPolicy receives SuspensionsService through its constructor and create awaits it.
The service might read a table, a cache or a feature-flag client; the policy neither knows nor cares. authorize(),
can() and the guard all await asynchronous abilities, so callers do not change when an ability becomes async. A policy
registered in a feature module can inject that module’s providers; one listed in the policies option of forRoot()
lives in the authorization module instead and needs the imports option to reach its dependencies.
// policies/suspensions.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class SuspensionsService {
// Stands in for a table of moderation decisions.
private readonly suspended = new Set<string>(['spammer']);
async isSuspended(userId: string): Promise<boolean> {
return this.suspended.has(userId);
}
}
Policies must be singletons: a request-scoped or transient policy, or one that depends on a request-scoped provider, throws at startup. Keep per-request state out of policies and pass what an ability needs as an argument.
Exposing permissions to the client
A frontend should not duplicate the rules to decide whether to render an "Edit" button. can() never throws on a
denial (it resolves to false for guests and forbidden users alike), which makes it the tool for shaping responses.
ReviewsService.findAll() above attaches a can object to every review, built from the very abilities that
PATCH and DELETE enforce, so the buttons and the endpoints cannot disagree. A user who is both the author of two of the
reviews and a moderator received in the test:
{
"r1": { "update": false, "delete": true },
"r2": { "update": true, "delete": true },
"r3": { "update": true, "delete": false }
}
Here the caller is the author of r2 and r3 and also a moderator (the output is trimmed to the seeded reviews), so he may edit his own and delete anyone’s, except r3, which is under legal hold. Each flag
runs the ability once per row, including any asynchronous work, so cache slow lookups or load them once per request.
Auditing denials
Responses do not say which check refused a caller. AuthorizationEvents exposes every denial on its events$
observable (and on a diagnostics channel), and can() returning false is not a denial and emits nothing:
const denials: AuthorizationDeniedEvent[] = [];
moduleRef.get(AuthorizationEvents).events$.subscribe((event) => denials.push(event));
The event of the guest’s POST /reviews in the test was:
{ "type": "denied", "policy": "ReviewPolicy", "ability": "create", "reason": "unauthenticated", "user": null, "args": [], "handler": "ReviewsController.create" }
Each event carries the user and the record as the policy saw them, so log the fields you need rather than the whole event.
Pitfalls specific to policies
-
Every protected endpoint needs a check: the global guard lets handlers without
@Can()through, so an endpoint with neither@Can()nor anauthorize()call in its service is open to every signed-in user. -
In a hybrid application, a
@Can()on a message handler is enforced only whenconnectMicroservice()is called withinheritAppConfig: true; without it the guard does not run. -
A response cache keyed by URL (for example
CacheInterceptor) answers before the handler runs, so it would serve one user’s record to another without asking the policy. Cache below the check, or per user. -
Re-check state when you write: authorization reads the record, and two concurrent requests can both see it in the allowed state. Make the write conditional and send an idempotency key to external systems.
-
Filter lists in the query with a question the query can use ("may this user see every review?"), not by loading every row and calling
can()on each.
Claims and attribute-based access
Attribute-based access control (ABAC) decides from attributes rather than named roles: of the subject (the caller:
plan, verified email, tenant), of the resource (its tenant, owner, status) and of the environment (the time, the
network). Claims are how the subject’s attributes usually arrive: a verified token carries them as claims, and the
authentication step copies the ones the application needs onto request.user. RBAC is the special case where the only
attribute is a role, so ABAC is the answer when roles start multiplying.
The metadata decorator below declares the attributes a route needs, with no role in sight:
// claims/claims.decorator.ts
import { Reflector } from '@nestjs/core';
import type { AuthUser } from '../auth/user.js';
/** Attribute requirements. Every key that is present must match. */
export interface ClaimRule {
plan?: Array<NonNullable<AuthUser['plan']>>;
emailVerified?: boolean;
}
export const RequireClaims = Reflector.createDecorator<ClaimRule>();
// claims/claims.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedRequest } from '../auth/user.js';
import { RequireClaims } from './claims.decorator.js';
@Injectable()
export class ClaimsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const rule = this.reflector.getAllAndOverride(RequireClaims, [context.getHandler(), context.getClass()]);
if (!rule) {
return true;
}
const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!user) {
throw new UnauthorizedException();
}
if (rule.plan && !(user.plan && rule.plan.includes(user.plan))) {
throw new ForbiddenException('This feature needs a different plan');
}
if (rule.emailVerified !== undefined && user.emailVerified !== rule.emailVerified) {
throw new ForbiddenException('Verify your email address first');
}
return true;
}
}
// claims/reports.controller.ts
import { Controller, Get, Param, Post } from '@nestjs/common';
import type { AuthUser } from '../auth/user.js';
import { CurrentUser } from '../policies/current-user.decorator.js';
import { RequireClaims } from './claims.decorator.js';
import { ReportsService } from './reports.service.js';
@Controller('reports')
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get(':id')
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.reports.findOne(user, id);
}
// Attribute-based: no role is named, only properties of the caller.
@Post('export')
@RequireClaims({ plan: ['pro'], emailVerified: true })
export() {
return { queued: true };
}
}
// claims/reports.service.ts
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { AuthUser } from '../auth/user.js';
export interface Report {
id: string;
tenantId: string;
title: string;
}
@Injectable()
export class ReportsService {
private readonly reports: Report[] = [
{ id: 'rep1', tenantId: 'acme', title: 'Acme loans' },
{ id: 'rep2', tenantId: 'globex', title: 'Globex loans' },
];
/** Resource attribute (tenantId) compared with a subject attribute (the token's tenant claim). */
findOne(user: AuthUser, id: string): Report {
const report = this.reports.find((candidate) => candidate.id === id);
if (!report || report.tenantId !== user.tenantId) {
// 404 for both cases, so a caller cannot probe which ids exist in other tenants.
throw new NotFoundException();
}
return report;
}
/** Environment attribute: a rule that depends on the clock, not on the caller. */
closeMonth(user: AuthUser, now: Date): string {
if (now.getUTCDate() < 28) {
throw new ForbiddenException('Month-end close opens on the 28th');
}
return `closed by ${user.id}`;
}
}
The service shows all three attribute families. The tenant comparison is a resource attribute checked against a subject
attribute; the month-end rule is an environment attribute that depends on the clock instead of the caller, which is why
the clock is a parameter and the rule is trivially testable with a fixed date. Answering 404 for both a missing report
and another tenant’s report stops a caller from probing which ids exist elsewhere.
POST /reports/export plan=free, email_verified=true -> 403 {"message":"This feature needs a different plan",...}
POST /reports/export plan=pro, email_verified=true -> 201 {"queued":true}
GET /reports/rep2 tenant=acme -> 404
Three pitfalls apply to claims. First, a claim is a snapshot: a token issued before an upgrade or a suspension keeps its
old plan until it expires, so keep access tokens short-lived (the example uses 15 minutes) and look up volatile
attributes such as suspensions in a service, as SuspensionsService does above. Second, claims are only as trustworthy
as the verification: always verify the signature with verifyAsync(), never decode a token and trust it. Third, an ever
growing if ladder in a guard is a sign that the rules want a policy class or CASL, with the rules in one testable place.
CASL: the established alternative
CASL is an isomorphic authorization library with its own rule language: can, cannot, a wildcard
manage action and all subject, conditions written as MongoDB-style queries, per-field permissions and inverted rules.
The NestJS authorization page no longer contains a CASL section (the 2026 restructure replaced it with
@nestjs/authorization), but CASL remains a mature, widely used choice and is the sensible one when the same rules must
also run in a browser, when a rule must be translated into a database filter (@casl/prisma, @casl/mongoose), or when
you want something that is already 1.0 and stable. It integrates with Nest through three small pieces: a factory that
builds the rules for one caller, a decorator that declares what a route checks, and a guard that runs it.
// casl/casl-ability.factory.ts
import { AbilityBuilder, createMongoAbility, type ForcedSubject, type MongoAbility } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import type { AuthUser } from '../auth/user.js';
import type { Review } from './review.entity.js';
export type Action = 'manage' | 'create' | 'read' | 'update' | 'delete';
/** A subject is its type name, or a record tagged with that name by `subject('Review', record)`. */
export type AppSubject = 'Review' | (Review & ForcedSubject<'Review'>) | 'all';
export type AppAbility = MongoAbility<[Action, AppSubject]>;
const PUBLIC_FIELDS: Array<keyof Review> = ['id', 'authorId', 'text'];
@Injectable()
export class CaslAbilityFactory {
/** Builds the rules for one caller. Build it per request: it is cheap and holds the user's id. */
createForUser(user: AuthUser): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
// Field lists add up: a rule without `fields` would allow every field.
can('read', 'Review', PUBLIC_FIELDS);
can('read', 'Review', ['internalNotes', 'legalHold'], { authorId: user.id });
if (user.roles.includes('moderator')) {
can('read', 'Review', ['internalNotes', 'legalHold']);
}
if (user.roles.includes('admin')) {
can('manage', 'all'); // `manage` is CASL's wildcard action, `all` its wildcard subject
} else {
can('create', 'Review');
can(['update', 'delete'], 'Review', { authorId: user.id }); // conditions are matched against the record
}
// An inverted rule that comes last wins over the allowing rules before it, even for admins.
cannot('delete', 'Review', { legalHold: true }).because('Reviews under legal hold cannot be deleted');
return build();
}
}
Source: CASL: Define rules
Three details deserve attention. Rules are evaluated last to first, so the inverted cannot('delete', 'Review', { legalHold: true })
at the end overrides the earlier manage rule even for admins. Records are passed through subject('Review', record),
which tags a plain object with its type name, the approach that suits ORM results that are not class instances. And
field lists add up: a rule without fields allows every field, so a restrictive list is only restrictive if no other
rule for the same action and subject omits fields.
// casl/check-policies.decorator.ts
import { Reflector } from '@nestjs/core';
import type { AppAbility } from './casl-ability.factory.js';
export type PolicyHandler = (ability: AppAbility) => boolean;
/** `@CheckPolicies((ability) => ability.can('create', 'Review'))` */
export const CheckPolicies = Reflector.createDecorator<PolicyHandler>();
// casl/policies.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedRequest } from '../auth/user.js';
import { CaslAbilityFactory } from './casl-ability.factory.js';
import { CheckPolicies } from './check-policies.decorator.js';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly abilities: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const handler = this.reflector.getAllAndOverride(CheckPolicies, [context.getHandler(), context.getClass()]);
if (!handler) {
return true;
}
const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!user) {
throw new UnauthorizedException();
}
if (!handler(this.abilities.createForUser(user))) {
throw new ForbiddenException();
}
return true;
}
}
Source: CASL: Introduction
Record-level and field-level checks happen in the handler or the service. ForbiddenError.from(ability).throwUnlessCan()
throws CASL’s own error, which is not an HttpException, so without a filter it would surface as a 500. A small filter
maps it to 403. permittedFieldsOf() from @casl/ability/extra computes the fields a caller may read from the rules
that match one record, which is how the response below drops internalNotes for everybody except the author and
moderators.
// casl/casl-reviews.controller.ts
import { Body, Controller, Delete, Get, HttpCode, NotFoundException, Param, Patch, Post, UseFilters } from '@nestjs/common';
import { permittedFieldsOf } from '@casl/ability/extra';
import { ForbiddenError, subject } from '@casl/ability';
import type { AuthUser } from '../auth/user.js';
import { CurrentUser } from '../policies/current-user.decorator.js';
import { CaslAbilityFactory } from './casl-ability.factory.js';
import { CaslForbiddenFilter } from './casl-forbidden.filter.js';
import { CheckPolicies } from './check-policies.decorator.js';
import { newReview, type Review } from './review.entity.js';
@UseFilters(CaslForbiddenFilter)
@Controller('casl/reviews')
export class CaslReviewsController {
private readonly reviews: Review[] = [
newReview({ id: 'r1', authorId: 'alice', text: 'Great', internalNotes: 'checked' }),
newReview({ id: 'r2', authorId: 'bob', text: 'Disputed', internalNotes: 'lawyers', legalHold: true }),
];
constructor(private readonly abilities: CaslAbilityFactory) {}
@Post()
@CheckPolicies((ability) => ability.can('create', 'Review')) // route-level: no record yet
create(@CurrentUser() user: AuthUser, @Body('text') text: string) {
const review = newReview({ id: `r${this.reviews.length + 1}`, authorId: user.id, text });
this.reviews.push(review);
return review;
}
@Get(':id')
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const review = this.find(id);
const ability = this.abilities.createForUser(user);
// Field-level: the fields this caller may read, taken from the rules that matched this record.
const fields = permittedFieldsOf(ability, 'read', subject('Review', review), {
fieldsFrom: (rule) => rule.fields ?? [],
});
return Object.fromEntries(fields.map((field) => [field, review[field as keyof Review]]));
}
@Patch(':id')
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('text') text: string) {
const review = this.find(id);
// Record-level: throws CASL's ForbiddenError, which the filter maps to 403.
ForbiddenError.from(this.abilities.createForUser(user)).throwUnlessCan('update', subject('Review', review));
review.text = text;
return review;
}
@Delete(':id')
@HttpCode(204)
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const review = this.find(id);
ForbiddenError.from(this.abilities.createForUser(user)).throwUnlessCan('delete', subject('Review', review));
this.reviews.splice(this.reviews.indexOf(review), 1);
}
private find(id: string) {
const review = this.reviews.find((candidate) => candidate.id === id);
if (!review) {
throw new NotFoundException();
}
return review;
}
}
// casl/casl-forbidden.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, ForbiddenException, HttpException } from '@nestjs/common';
import { ForbiddenError } from '@casl/ability';
import { BaseExceptionFilter } from '@nestjs/core';
/** CASL's ForbiddenError is not an HttpException; without a filter it becomes a 500. */
@Catch(ForbiddenError)
export class CaslForbiddenFilter extends BaseExceptionFilter implements ExceptionFilter {
catch(error: ForbiddenError<never>, host: ArgumentsHost) {
const exception: HttpException = new ForbiddenException(error.message);
super.catch(exception, host);
}
}
Source: CASL: @casl/ability/extra
The example prints:
{"message":"Cannot execute \"update\" on \"Review\"","error":"Forbidden","statusCode":403}
{"message":"Reviews under legal hold cannot be deleted","error":"Forbidden","statusCode":403}
[{"id":"r1","authorId":"alice","text":"ok"},
{"id":"r1","authorId":"alice","text":"ok","internalNotes":"checked","legalHold":false}]
The first two lines are the 403 bodies for editing someone else’s review and for deleting a held review as an admin; the last two are the same review read by an unrelated user and by its author.
Choosing between the approaches
| Roles + guard | @nestjs/authorization |
Claims / ABAC guard | CASL | |
|---|---|---|---|---|
Maturity |
Core Nest, stable |
Pre-1.0 (0.0.1) |
Your own code |
Stable library (7.x) |
Record-level checks |
No |
Yes, typed policy methods |
By hand in services |
Yes, conditions |
Dependency injection |
Guard only |
Policies are providers |
Guard only |
Factory is a provider |
Field-level |
No |
Not documented |
No |
Yes, |
Rules in the browser |
No |
No |
No |
Yes (isomorphic) |
Best for |
Coarse, stable roles |
Nest-native rules with services |
Plan, tenant or flag gates |
Complex rule sets, shared rules |
Starting with roles and adding @nestjs/authorization for record-level rules is a reasonable path for a new
application, provided the pre-1.0 status is acceptable. CASL is the safe pick when stability matters more than staying
inside the Nest packages.
Field-level authorization in GraphQL
GraphQL clients choose the fields, so a route-level check on Query.reviews says nothing about a sensitive field such as
a reviewer’s email: the schema is the contract, and every field is a potential leak. Nest offers two mechanisms, and the
choice follows the cost of the check.
Guards (and so @Can()) do not run for field resolvers by default; they run for the root Query or Mutation method
only. Setting fieldResolverEnhancers: ['guards'] on GraphQLModule makes Nest run them for every @ResolveField()
method as well. The cost is one guard run per returned row, so authentication must not re-verify the token on each
field; the docs' isResolvingGraphQLField() helper lets a guard skip the work for field-level calls. In a guard, the
generic ExecutionContext must first be converted with GqlExecutionContext.create(context) to reach the resolver
arguments and the context object.
// graphql/gql-request.ts
import type { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext, type GqlContextType } from '@nestjs/graphql';
import type { AuthenticatedRequest } from '../auth/user.js';
/** The Express/Fastify request that the GraphQL `context` factory exposed as `req`. */
export function gqlRequest(context: ExecutionContext): AuthenticatedRequest {
return GqlExecutionContext.create(context).getContext<{ req: AuthenticatedRequest }>().req;
}
/** True when Nest runs an enhancer for a field of an object type rather than for a Query/Mutation. */
export function isResolvingGraphQLField(context: ExecutionContext): boolean {
if (context.getType<GqlContextType>() === 'graphql') {
const { parentType } = GqlExecutionContext.create(context).getInfo();
return parentType.name !== 'Query' && parentType.name !== 'Mutation';
}
return false;
}
// graphql/gql-authenticate.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TokenService } from '../auth/token.service.js';
import { gqlRequest, isResolvingGraphQLField } from './gql-request.js';
@Injectable()
export class GqlAuthenticateGuard implements CanActivate {
constructor(private readonly tokens: TokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (isResolvingGraphQLField(context)) {
return true; // request.user was set by the root Query or Mutation; do not verify the token per field
}
const request = gqlRequest(context);
request.user = await this.tokens.userFrom(request.headers.authorization as string | undefined);
return true;
}
}
For a check that needs the full power of a policy (injected services, record access), put @Can() on a
@ResolveField() method. For a cheap check that depends only on metadata, use field middleware together with the
@Extensions() decorator: the role a field needs is declared on the model, and a middleware compares it with the caller.
Field middleware cannot inject providers and is meant to be lightweight; it reads the user from the GraphQL context that
the module’s context factory built, and either throws (an error in the errors array) or returns null (the value is
hidden).
// graphql/review.model.ts
import { Field, ID, ObjectType, Extensions } from '@nestjs/graphql';
import type { FieldMiddleware, MiddlewareContext, NextFn } from '@nestjs/graphql';
import type { AuthenticatedRequest, Role } from '../auth/user.js';
/**
* Field middleware cannot inject providers, so it reads what it needs from the GraphQL
* context (set by the guard) and from the field's extensions (declared on the model).
*/
export const checkRoleMiddleware: FieldMiddleware = async (ctx: MiddlewareContext, next: NextFn) => {
const info = ctx.info!;
const { extensions } = info.parentType.getFields()[info.fieldName];
const user = (ctx.context as { req: AuthenticatedRequest }).req.user;
const needed = extensions.role as Role;
if (!user?.roles.includes(needed) && !user?.roles.includes('admin')) {
return null; // hide the value; throw a ForbiddenException instead to report an error
}
return next();
};
@ObjectType('Review')
export class ReviewModel {
@Field(() => ID)
id: string;
@Field()
text: string;
// Nullable, because the middleware answers null to callers without the role.
@Field(() => String, { nullable: true, middleware: [checkRoleMiddleware] })
@Extensions({ role: 'moderator' })
reviewerEmail?: string;
// Resolved by ReviewsResolver, which guards it with @Can().
@Field(() => String, { nullable: true })
internalNotes?: string;
}
// graphql/review-fields.policy.ts
import { Policy } from '@nestjs/authorization';
import type { AuthUser } from '../auth/user.js';
@Policy()
export class ReviewFieldsPolicy {
// Route-level shape: only the user, and `null` (a guest) is allowed as a caller.
viewInternalNotes(user: AuthUser | null) {
return !!user?.roles.some((role) => role === 'moderator' || role === 'admin');
}
}
// graphql/reviews.resolver.ts
import { Args, ID, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Can } from '@nestjs/authorization';
import { ReviewFieldsPolicy } from './review-fields.policy.js';
import { ReviewModel } from './review.model.js';
@Resolver(() => ReviewModel)
export class ReviewsResolver {
private readonly store: ReviewModel[] = [
{ id: 'r1', text: 'Great', reviewerEmail: 'alice@example.test', internalNotes: 'checked' },
{ id: 'r2', text: 'Dull', reviewerEmail: 'bob@example.test', internalNotes: 'spam?' },
];
@Query(() => [ReviewModel])
reviews() {
return this.store;
}
// Root-level check: runs once per operation, before the resolver.
@Mutation(() => Boolean)
@Can(ReviewFieldsPolicy, 'viewInternalNotes') // stands in for a "moderate" ability
hideReview(@Args('id', { type: () => ID }) id: string) {
return this.store.some((review) => review.id === id);
}
// Field-level check: this guard runs once per returned review, because
// fieldResolverEnhancers: ['guards'] is enabled in the module.
@ResolveField(() => String, { nullable: true, name: 'internalNotes' })
@Can(ReviewFieldsPolicy, 'viewInternalNotes')
internalNotes(@Parent() review: ReviewModel) {
return review.internalNotes;
}
}
// graphql/app.module.ts
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { AuthorizationModule } from '@nestjs/authorization';
import { GraphQLModule } from '@nestjs/graphql';
import type { AuthenticatedRequest } from '../auth/user.js';
import { AuthModule } from '../auth/auth.module.js';
import { GqlAuthenticateGuard } from './gql-authenticate.guard.js';
import { ReviewFieldsPolicy } from './review-fields.policy.js';
import { ReviewsResolver } from './reviews.resolver.js';
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
AuthModule, // TokenService and JwtModule only: GraphQL registers its own guard below
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
graphiql: false,
context: ({ req }: { req: AuthenticatedRequest }) => ({ req }), // expose the request, so guards and field middleware reach request.user
fieldResolverEnhancers: ['guards'], // run guards for @ResolveField() methods as well
}),
AuthorizationModule.forRoot(),
],
providers: [ReviewsResolver, ReviewFieldsPolicy, { provide: APP_GUARD, useClass: GqlAuthenticateGuard }],
})
export class AppModule {}
Querying { reviews { id reviewerEmail internalNotes } } as a guest, as a reader and as a moderator gave, condensed to the relevant parts:
{ "guest": { "data": { "reviews": [{ "id": "r1", "reviewerEmail": null, "internalNotes": null }] },
"errors": [{ "path": ["reviews", 0, "internalNotes"], "extensions": { "code": "UNAUTHENTICATED" } }] },
"reader": { "data": { "reviews": [{ "id": "r1", "reviewerEmail": null, "internalNotes": null }] },
"errors": [{ "path": ["reviews", 0, "internalNotes"], "extensions": { "code": "FORBIDDEN" } }] },
"moderator": { "data": { "reviews": [{ "id": "r1", "reviewerEmail": "alice@example.test", "internalNotes": "checked" }] } } }
Both fields are nullable on purpose: a field that denies must be allowed to be null, otherwise GraphQL null-propagates
the error up to the parent object and the whole review disappears. The two mechanisms differ in how they fail: the
middleware hides the value silently, which is right for data that should simply not exist for this caller, while the
guard reports a coded error, which is right when the client should know it asked for something it may not have. The
Mutation check runs once per operation at the root (the test showed a guest getting UNAUTHENTICATED and a moderator
true). Field middleware applies only to ObjectType classes and, with field-level enhancers enabled, runs before the
guards and interceptors bound to the method; see GraphQL authorization for the
language-neutral treatment, and GraphQL: Code-first and Schema-first
for the module setup.
Express and Fastify
Nothing in the policies or the guards depends on the HTTP platform: the RBAC, policy, claims and CASL examples passed the
same assertions on Express 5 and Fastify 5. The one trap is where the user is loaded. A function middleware receives
the raw Node request on Fastify, not the Fastify request, so a property that a middleware assigns is not visible as
request.user in a guard. In a test with the same middleware and guard on both adapters, Express showed the user on
request.user, while on Fastify request.user was undefined and the value sat on request.raw.user:
const request = context.switchToHttp().getRequest();
request.user; // Express: { id: 'u1' } set by the middleware. Fastify: undefined
request.raw.user; // Fastify: { id: 'u1' }, assigned on the raw Node request by the middleware
Source: NestJS: Middleware
This is why the examples load the user in a guard that writes request.user itself: the code is identical on both
platforms. For GraphQL, what a guard sees as req is
whatever the driver’s context factory returns, so confirm that shape per driver when leaving Express with Apollo. See
Fastify and HTTP Platforms.
Where authorization sits in the request lifecycle
Authorization is a guard-stage concern: middleware (CORS, body parsing, request ids) runs first, then global, controller
and route guards in that order, then interceptors, pipes and the handler. Two consequences follow. A guard sees the
request before validation, so it must not trust the body and should decide only from the caller and the route. And a
record-level decision happens inside the handler, after pipes have validated the parameters, which is why denying with
authorize() in the service still costs a database read; if the existence of the record is sensitive, deny with a 404.
Exception filters see the resulting HttpException, so a filter can add an audit entry for every 401 and 403. The full
order is in Request Lifecycle.
Where the book is outdated
The Packt book that this section consulted builds route authorization by hand: a custom Roles decorator that wraps
SetMetadata('roles', …), and a role guard that reads the metadata with reflector.get() from the handler only, so a
class-level default is silently ignored. That pattern still works, and Role-based access control (RBAC) shows the current typed form
(Reflector.createDecorator() with getAllAndOverride() over handler and class). The book has no record-level
policies and no field-level examples: its GraphQL security section stays conceptual (expose the user on the context,
guard resolvers, protect fields with decorators or directives, consider GraphQL Shield) and gives no runnable field-level
code. Since then the official documentation has moved to @nestjs/authentication and @nestjs/authorization (both
pre-1.0), moved the Passport and JWT material to a recipe, and the GraphQL side has fieldResolverEnhancers, field
middleware with @Extensions() and the v14 changes (GraphiQL replacing Playground, graphql-ws only); use the forms
shown above rather than Apollo Server 2 or 3 APIs.
What changed recently
-
The 2026 documentation restructure moved authorization into the Security chapter and rewrote it around
@nestjs/authorization: policies,@Can(),AuthorizationService,before(), injection into policies andAuthorizationEvents. The package is0.0.x; its first release is dated 2026-09-29. -
The CASL integration that older versions of the page described is no longer part of the official page; the library itself is unaffected and its current major is 7.
-
Reflector.createDecorator()is the documented way to define route metadata, with@SetMetadata()the low-level alternative. -
@nestjs/graphql14 (Nest 12) uses GraphiQL andgraphql-ws;fieldResolverEnhancersand field middleware are unchanged in purpose.