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.

sequenceDiagram autonumber participant C as Client participant A as Authentication guard participant Z as Authorization guard participant H as Handler participant S as Service participant P as Policy C->>A: request with a bearer token A->>A: verify the token, set request.user A->>Z: next guard Z->>P: route-level ability (@Can) P-->>Z: true or false Z-->>C: 401 (guest) or 403 (signed in) when denied Z->>H: allowed H->>S: call with the user S->>S: load the record S->>P: record-level ability (authorize) P-->>S: true or false S-->>C: AuthorizationError mapped to 401 or 403 when denied S-->>H: record H-->>C: 200 with the response

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:

Typed decorators for the required roles and for public routes
// 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.

A roles guard with a role hierarchy
// 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.

A controller with a class-level default and per-route overrides
// 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() {}
}

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.

Wiring: authentication first, then the roles guard
// 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 {}

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.

The root module: authentication first, then authorization
// 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.

A policy for reviews: a route-level ability (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.

The controller: @Can() for the route-level check, the user passed on to the service for the rest
// 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.

The service: authorize() on writes, can() to shape a response
// 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.

The feature module
// 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

POST /reviews

401

create denies null; the denial is reported as unauthenticated

Suspended user

POST /reviews

403

create runs the injected SuspensionsService and denies

Signed-in user

PATCH /reviews/:id on someone else’s review

403

update compares authorId with the user

Guest

PATCH /reviews/:id

401

update would require a user; the module maps the denial for a guest to 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:

The hook from the policy above, in isolation
@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.

The injected service
// 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 an authorize() call in its service is open to every signed-in user.

  • In a hybrid application, a @Can() on a message handler is enforced only when connectMicroservice() is called with inheritAppConfig: 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:

A decorator and a guard for attribute requirements
// 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;
  }
}
A controller combining a route-level attribute rule and a record-level tenant rule
// 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.

Building the rules for a caller
// 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();
  }
}

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.

The decorator and the guard for route-level checks
// 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;
  }
}

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.

A controller using CASL for create, update, delete and field-level reads, and the filter that maps ForbiddenError to 403
// 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);
  }
}

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, permittedFieldsOf

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.

Request access and the field-aware authentication guard
// 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).

The model with a declarative role requirement, the middleware that enforces it, and a policy for the guarded field
// 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');
  }
}
The resolver and the module wiring
// 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

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 and AuthorizationEvents. The package is 0.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/graphql 14 (Nest 12) uses GraphiQL and graphql-ws; fieldResolverEnhancers and field middleware are unchanged in purpose.