When you add route protection in NestJS, the first instinct is to put the check wherever it's most convenient. As a result, part of the logic settles in the Guard, part in the controller, part inside the service. A month later it's already unclear: which layer is responsible for what and why one endpoint returns 401 while another returns 403 in the same situation.
In reality auth is three different questions, and each one is appropriate only at its own level.
Three questions — three levels
The first question: who is making the request? This is authentication — verifying the JWT signature. It's needed for every protected request, doesn't depend on the specific endpoint, and therefore lives at the Gateway or in a global Guard.
The second question: can this user call this endpoint at all? This is role-based authorization (RBAC). It's needed at the endpoint level: POST /admin/orders/refund — for admin only, POST /orders — for customer only.
The third question: does this user have access to this exact object? This is resource-based authorization (ABAC). It's needed inside the handler, after the object has been loaded from the database.
| Level | Question | NestJS mechanism |
|---|---|---|
| Gateway / global Guard | Who is this client? | JwtAuthGuard (passport-jwt + jwks-rsa) |
| Endpoint / controller | Is this role allowed? | @Roles(...) + RolesGuard |
| Handler | Is this user allowed this exact object? | comparison aggregate.ownerId === principal.sub |
Gateway: verifying the JWT signature
At this level the application only makes sure the token is genuine: the signature is correct, the token isn't expired, and it was issued by the right provider.
JwtStrategy is configured once for the whole application. It gets the public key from the provider's JWK Set, verifies the signature, exp, iss, aud, and returns a Principal — a typed object that NestJS puts into request.user.
// adapters/in/http/security/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000, // cache ~5 minutes
rateLimit: true,
}),
});
}
validate(claims: JwtClaims): Principal {
return { sub: claims.sub, roles: extractRoles(claims) };
}
}
The validate method is called only after the library has confirmed the token's signature and lifetime. A hand-rolled jwt.decode without signature verification is a serious security mistake: such a token is easy to forge.
If the JWT is invalid — JwtAuthGuard throws UnauthorizedException, and the request returns 401. The handlers are not called.
What the Gateway doesn't do here: it doesn't know which roles are required for a specific path, and it knows nothing about orders or users.
Controller: checking the role
After the JWT has passed verification, you need to make sure the user's role is suitable for the specific endpoint.
The global APP_GUARDs are registered in AppModule in the right order: first JwtAuthGuard (returns 401 on an invalid token), then RolesGuard (returns 403 on insufficient rights).
// app.module.ts
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
RolesGuard reads the decorator metadata and compares the user's roles with the required ones. If @Roles(...) isn't specified at all — the Guard denies the request: an endpoint without explicit markup is considered closed.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get(Roles, ctx.getHandler());
if (!required) throw new ForbiddenException();
const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
if (!required.some((r) => user.roles.includes(r))) throw new ForbiddenException();
return true;
}
}
The markup looks like this:
@Controller('orders')
export class OrderController {
@Post()
@Roles(['customer'])
async create(@Body() dto: CreateOrderDto, @Req() req: Request): Promise<OrderResponse> {
return this.handler.execute(dto, req.user as Principal);
}
@Get(':id')
@Roles(['customer', 'admin'])
async getById(@Param('id') id: string, @Req() req: Request): Promise<OrderResponse> {
return this.handler.execute({ orderId: id }, req.user as Principal);
}
}
POST /orders—customeronly.GET /orders/:id—customeroradmin.POST /admin/orders/:id/refund—adminonly.
If the role doesn't match — RolesGuard returns 403 before the request reaches the handler.
What the controller doesn't check here: it doesn't know whose order it is specifically. The customer role allows reading orders in principle — but not any order.
Handler: checking access to the object
The user's role permits access to the endpoint. But customer with sub='cust-99' must not read the order with customerId='cust-42'. This is checked only after loading the object from the database.
// core/order/handlers/get-order-by-id.handler.ts
@Injectable()
export class GetOrderByIdHandler {
constructor(private readonly orders: OrderRepository) {}
async execute(query: GetOrderByIdQuery, principal: Principal): Promise<Order> {
const order = await this.orders.byId(query.orderId);
if (!order) throw new OrderNotFoundError(query.orderId);
if (!principal.roles.includes('admin') && order.customerId !== principal.sub) {
throw new ForbiddenError(query.orderId);
}
return order;
}
}
The same model works for other aggregates:
// core/product/handlers/update-product.handler.ts
async execute(cmd: UpdateProductCommand, principal: Principal): Promise<void> {
const product = await this.products.byId(cmd.productId);
if (!product) throw new ProductNotFoundError(cmd.productId);
if (!principal.roles.includes('admin') && product.sellerId !== principal.sub) {
throw new ForbiddenError(cmd.productId);
}
// ...
}
The admin role bypasses the owner check — but such actions should go into the audit log.
This logic is placed in the Handler or moved into a separate @Injectable() AccessPolicy. In the controller — not allowed: the controller must not know about domain objects and their owners.
Why ABAC is not at the Gateway
It seems logical to move the "whose order" check to the system entrance. But the Gateway doesn't know the domain model.
A scenario where this breaks:
- The Gateway receives
GET /orders/order-12345, the JWT is valid,principal.sub='cust-99'. - The Gateway tries to answer the question "whose order is this" — for that it needs to go to the database or to order-service.
- The Gateway effectively becomes a second service with a copy of the domain model.
- When an order gets co-authors or delegation — the Gateway needs to be updated together with order-service.
The result: a double source of truth and blurred responsibility. The correct place for this check is where the Order aggregate lives.
Common mistakes
Not distinguishing 401 and 403. UnauthorizedException (401) — the token is invalid or missing. ForbiddenException (403) — the token is good, but the rights are insufficient. Confusing them means giving the client the wrong signal about what to do next.
An endpoint without @Roles(...) and without an explicit @Public(). Such an endpoint is dangerous: if RolesGuard finds no metadata, it's better to return 403 by default than to let the request through. The global APP_GUARDs work exactly this way.
Hand-rolled JWT parsing without signature verification. jwt.decode only decodes the payload but does not verify the signature. This means anyone can pass a forged token with the desired data, and the application will accept it.
ABAC in the controller. The controller must not load domain objects just to check rights. That's the handler's responsibility.
In short
- Auth is three different questions: who, is it allowed at all, is this specific object allowed.
- The JWT signature is verified once globally via
JwtAuthGuard— not in each Guard separately. - Roles are checked at the endpoint level via
@Roles(...)+RolesGuard. - Access to a specific resource is checked in the handler, after loading the object from the database.
- The Gateway doesn't know the domain model — ABAC has no place there.
- 401 — an invalid token; 403 — no rights. You must not confuse them.
- Every endpoint must have
@Roles(...)or an explicit@Public(). Otherwise — closed by default.
What to read next
- JWT validation in NestJS —
JwtStrategy,passportJwtSecret,jwks-rsa. - RBAC: role mapping —
extractRoles, allowed roles,RolesGuard. - ABAC: resource ownership —
AccessPolicy, the bypass foradmin.