Guards for Authorisation
A guard answers may this caller use this route; the check that protects a record has to happen where the record is loaded.
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>A guard is a class marked @Injectable() that implements CanActivate. Its one method, canActivate(context), returns a boolean, a Promise of one or an Observable of one. true lets the request continue. false makes Nest throw ForbiddenException, which the exceptions layer turns into a 403 with the body {"statusCode":403,"message":"Forbidden resource","error":"Forbidden"}. Throw your own exception instead when you want a different status, such as UnauthorizedException for a 401.
Where it runs
Guards run after all middleware and before any interceptor or pipe. Global guards go first, then controller guards, then route guards, each group in binding order. Because middleware has already run, request.user set by an authentication guard or middleware is available. Because pipes have not run, a guard sees the parsed body, not a validated DTO instance.
Telling the guard what the route needs
A guard is generic; the route declares its requirement as metadata. Reflector.createDecorator() builds a typed decorator, and Reflector reads it back inside the guard. getAllAndOverride() returns the first defined value from the targets you pass, which is how a handler-level @Roles() overrides a controller-level one.
The boundary that protects the data
A guard sees the request, not the row. @Roles('staff') on GET /orders/:id says staff may call the route; it cannot say this order belongs to this customer, because nothing has loaded the order yet. Ownership checks belong in the service, right after the record is fetched and before it is returned or changed. NestJS 12 adds @nestjs/authorization for exactly this split — though at the time of writing it is version 0.0.1 and brand new, so treat it as experimental and keep the guard above as the approach you rely on: @Can(OrderPolicy, 'viewAll') on the route, and AuthorizationService.authorize(OrderPolicy, 'view', user, order) in the service. @Can() accepts only an ability that can be called with the user alone and whose user parameter accepts null, because the guard cannot know whether a route has a signed-in user, so viewAll has to be typed user: User | null; an ability declared with User can only be called from a service.
Example
// src/auth/roles.guard.ts
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Role } from '../users/user.js';
import { Roles } from './roles.decorator.js';
Injectable()
export class RolesGuard implements CanActivate {
// Reflector is exported by @nestjs/core and provided by the framework.
// Injection only works if the guard is registered as a class (APP_GUARD
// or @UseGuards(RolesGuard)), not as `new RolesGuard()`.
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Returns the first defined value, checking the targets in order:
// a @Roles() on the handler wins over one on the controller class.
const required = this.reflector.getAllAndOverride(Roles, [
context.getHandler(),
context.getClass(),
]);
// No @Roles() anywhere: this guard has no opinion about this route.
// Returning false here would lock down every unannotated route.
if (!required) {
return true;
}
// Guards run after middleware, so whatever authenticated the request
// has already put the user on it.
const request = context
.switchToHttp()
.getRequest<{ user?: { id: string; roles: Role[] } }>();
const user = request.user;
// 401: we do not know who this is. Returning false would say 403,
// which tells the client to stop retrying rather than to sign in.
if (!user) {
throw new UnauthorizedException();
}
if (!required.some((role) => user.roles.includes(role))) {
// Same 403 as returning false, with a message of your own.
throw new ForbiddenException('Insufficient role');
}
return true;
}
}When to use it
- Restrict an admin controller to users whose token carries the staff role.
- Let a customer read only their own orders, while staff read all of them.
- Return 401 for an unauthenticated caller and 403 for an authenticated one without the role.
- Move a rule that two controllers and a queue worker all need into one place.
More examples
The typed decorator, and the @SetMetadata longhand
Both forms are in use in real code, so you need to recognise each. @SetMetadata('roles', ['admin']) with reflector.getAllAndOverride('roles', ...) is the original, string-keyed form and is still supported; Reflector.createDecorator() is the same mechanism with the key hidden and the value type checked. Note that getAllAndMerge() exists too: it concatenates the handler and class values instead of letting one win, which is what you want when roles accumulate rather than override.
// src/auth/roles.decorator.ts
import { Reflector } from '@nestjs/core';
import type { Role } from '../users/user.js';
// Creates a decorator that takes ONE argument of type Role[].
// Usage is @Roles(['admin']) - an array, not a list of arguments.
export const Roles = Reflector.createDecorator<Role[]>();
// The longhand it replaces. These two guards do the same thing:
//
// @SetMetadata('roles', ['admin']) // on the handler
// this.reflector.getAllAndOverride<Role[]>('roles', [
// context.getHandler(),
// context.getClass(),
// ]); // in the guard
//
// The string key is untyped on both sides, so a typo in either place
// compiles and the guard just reads undefined. createDecorator() ties
// the key and the value type together, which is why it is preferred.
// src/orders/orders.controller.ts
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { RolesGuard } from '../auth/roles.guard.js';
import { Roles } from '../auth/roles.decorator.js';
import { OrdersService } from './orders.service.js';
@Controller('orders')
// Applies to every handler in this controller. @UseGuards() also accepts
// a comma-separated list, which then runs in the order written.
@UseGuards(RolesGuard)
@Roles(['customer', 'staff'])
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.orders.findOne(id);
}
// Overrides the controller-level @Roles() for this handler only,
// because the guard uses getAllAndOverride().
@Roles(['staff'])
@Post(':id/refund')
refund(@Param('id') id: string) {
return this.orders.refund(id);
}
}The check that needs the record
This is the pattern behind the phrase check at the boundary that protects the data. Any route that takes an id - get one, update one, delete one, refund one - needs this, because the guard runs before the id means anything. Two details matter: the 404 comes before the 403 so the error does not leak which ids exist, and lists are narrowed in the query rather than filtered afterwards.
// src/orders/orders.service.ts
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { User } from '../users/user.js';
import { OrdersRepository } from './orders.repository.js';
@Injectable()
export class OrdersService {
constructor(private readonly repository: OrdersRepository) {}
async findOne(user: User, id: string) {
const order = await this.repository.findById(id);
// 404 before the ownership check, so a caller cannot use the
// 403/404 difference to learn which order ids exist.
if (!order) {
throw new NotFoundException();
}
// This is the check a route guard could not make: it needs the row.
// The guard already established that the caller may use this route.
const isOwner = order.userId === user.id;
const isStaff = user.roles.includes('staff');
if (!isOwner && !isStaff) {
throw new ForbiddenException();
}
return order;
}
async findAll(user: User) {
// Lists are filtered in the query, not checked record by record.
// A per-row check after loading every order is both slow and easy
// to forget on the next endpoint that lists orders.
return user.roles.includes('staff')
? this.repository.findAll()
: this.repository.findAllByUser(user.id);
}
}Policies with @nestjs/authorization
This is where NestJS authorisation is heading, and it is worth recognising. It is not yet where you should put a production service: at 0.0.1 the API can still change under you. Reach for this package when the rules outgrow a roles array - when refunding depends on the order's status and the staff member's limit, and the same rule is needed in a controller, a queue worker and a WebSocket gateway. AuthorizationModule.forRoot() registers AuthorizationGuard globally to enforce @Can() on routes, and it must be imported after your authentication module, because its guard reads request.user. The guard lets handlers without @Can() through untouched, so a new route with neither @Can() nor an authorize() call is open to every signed-in user.
// src/orders/order.policy.ts
// npm i @nestjs/authorization
// EXPERIMENTAL: version 0.0.1, published days before this was written.
// The CanActivate guard above is the stable way to do this today.
import { Policy } from '@nestjs/authorization';
import type { User } from '../users/user.js';
import type { Order } from './order.js';
// A policy is an ordinary provider. Each method whose first parameter is
// the user is an "ability". Only a literal true allows; any other value,
// even a truthy one, denies.
@Policy()
export class OrderPolicy {
// No record to pass, so a route guard can call this one with @Can() -
// but only because the user parameter accepts null. @Can() rejects an
// ability typed `user: User`, since it cannot know whether a route has
// a signed-in user; such an ability is for services only.
viewAll(user: User | null) {
return !!user?.roles.includes('staff');
}
// Takes the order, so only a service that has loaded it can ask.
view(user: User, order: Order) {
return order.userId === user.id || user.roles.includes('staff');
}
refund(user: User, order: Order) {
return user.roles.includes('staff') && order.status === 'paid';
}
}
// src/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { AuthorizationService } from '@nestjs/authorization';
import { OrderPolicy } from './order.policy.js';
import type { User } from '../users/user.js';
import { OrdersRepository } from './orders.repository.js';
@Injectable()
export class OrdersService {
constructor(
private readonly authorization: AuthorizationService,
private readonly repository: OrdersRepository,
) {}
async findAll(user: User) {
// can() resolves to true or false and decides the query.
return (await this.authorization.can(OrderPolicy, 'viewAll', user))
? this.repository.findAll()
: this.repository.findAllByUser(user.id);
}
async refund(user: User, id: string) {
const order = await this.repository.findByIdOrFail(id);
// authorize() throws when the ability denies. AuthorizationModule
// turns that into a 401 or a 403 once it leaves the handler.
await this.authorization.authorize(OrderPolicy, 'refund', user, order);
return this.repository.markRefunded(order);
}
}
Discussion