When I build a public-facing REST API, I am always worried I will forget to secure one endpoint. At the same time, I want to avoid boilerplate and noisy controllers.
In this post I show a pattern that forces me to make an explicit choice for every endpoint, without making the code harder to read.
The problem with per-endpoint guards
Many examples show something like this:
@UseGuards(AuthGuard('jwt'), PermissionsGuard)
@Put()
@Permissions('update:items')
update(@Body('item') item: Item) {
this.itemsService.update(item);
}
This works, but if you forget @UseGuards(...) on a new endpoint, that endpoint will accidentally become public.
It gets even more confusing if you do add @Permissions(...). It looks secure because you can see permissions on the method, but if you forgot the PermissionsGuard, those permissions are just metadata and nothing is enforced:
// Looks protected, but it is not
@Put()
@Permissions('update:items')
update(@Body('item') item: Item) {
this.itemsService.update(item);
}
Also, repeating @UseGuards(...) everywhere makes your controllers cluttered.
Goal
Authentication is global.
Authorization is global.
Controllers stay clean: no
@UseGuards(...)per endpoint.Every endpoint is explicitly either:
@Public()(no auth needed), or@Permissions(...)(permission needed)
If neither decorator exists, return 500 (error on our side).
1) Use two global guards
Use:
- a global AuthenticationGuard
- a global PermissionsGuard
Register them with APP_GUARD (instead of useGlobalGuards()) so you can keep using dependency injection.
// app.module.ts
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
@Module({
providers: [
{ provide: APP_GUARD, useClass: AuthenticationGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
})
export class AppModule {}
2) Keep controllers simple
With global guards, you only declare permissions (or public access).
@Put()
@Permissions('update:items')
update(@Body('item') item: Item) {
this.itemsService.update(item);
}
If an endpoint must be open:
@Get('health')
@Public()
health() {
return { ok: true };
}
3) Decorators
// permissions.decorators.ts
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "authorization:isPublic";
export const PERMISSIONS_KEY = "authorization:permissions";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const Permissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);
4) Fail closed: throw 500 if no decorator exists
This ensures safety: every endpoint must choose @Public() or @Permissions(...).
// permissions.guard.ts
import { CanActivate, ExecutionContext, Injectable, InternalServerErrorException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from "./permissions.decorators";
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const handler = ctx.getHandler();
const cls = ctx.getClass();
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [handler, cls]);
if (isPublic) return true;
const permissions = this.reflector.getAllAndOverride<string[] | undefined>(PERMISSIONS_KEY, [handler, cls]);
if (!permissions || permissions.length === 0) {
throw new InternalServerErrorException(`Missing @Public() or @Permissions() on ${cls.name}.${handler.name} (error on our side)`);
}
// ... the rest of your authorization logic
return true;
}
}
With this setup, we do not have to attach guards to every endpoint, but we are still forced to choose: public, or protected with specific permissions.
This does not solve everything. You still need to think about your permission model, and you should not blindly copy/paste permissions. It is also a good idea to add tests that verify your endpoints are not accidentally public.
A working example: https://github.com/rensjaspers/nestjs-secure-by-default-demo