Guards, authentication and authorisation
Guards answer one question: may this request proceed? Getting the difference between authentication and authorisation right is where most back-end vulnerabilities live.
A guard runs before the route handler and returns a boolean. false produces a 403; throwing gives you control of the response.
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService, private readonly reflector: Reflector) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
ctx.getHandler(), ctx.getClass(),
]);
if (isPublic) return true;
const req = ctx.switchToHttp().getRequest();
const token = req.headers.authorization?.replace(/^Bearer /, '');
if (!token) throw new UnauthorizedException();
try {
req.user = await this.jwt.verifyAsync(token);
return true;
} catch {
throw new UnauthorizedException();
}
}
}Default deny#
Register the guard globally and opt routes out, never the reverse.
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);
@Public()
@Post('login')
login(@Body() dto: LoginDto) {}Roles#
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>('roles', [
ctx.getHandler(), ctx.getClass(),
]);
if (!required?.length) return true;
const { user } = ctx.switchToHttp().getRequest();
return required.some((r) => user?.roles?.includes(r));
}
}@Roles(Role.Admin)
@Delete(':id')
remove(@Param('id') id: string) {}The check that gets forgotten#
Roles answer what kind of user is this. They do not answer is this their resource, and that second question is where the real vulnerabilities are.
@Get(':id')
findOne(@Param('id') id: string) {
return this.invoices.findOne(id); // any authenticated user, any invoice
}Ownership depends on data, so it belongs in the service, where the query happens:
async findOne(id: string, requester: AuthUser): Promise<Invoice> {
const invoice = await this.repo.findById(id);
if (!invoice || invoice.ownerId !== requester.id) {
throw new NotFoundException(); // not Forbidden: do not confirm it exists
}
return invoice;
}Return 404 rather than 403 for resources the caller may not see. A 403 tells an attacker the id is real.
And write the test, once per resource type:
it('does not let a user read another user’s invoice', async () => {
await request(app.getHttpServer())
.get(`/invoices/${bobsInvoice.id}`)
.set('Authorization', aliceToken)
.expect(404);
});Guards, interceptors, pipes, filters#
The execution order, which is worth memorising:
middleware -> guards -> interceptors (before) -> pipes -> handler
-> interceptors (after) -> exception filters- Guard — may this proceed? Auth, roles, rate limits.
- Pipe — transform and validate input. DTO validation.
- Interceptor — wrap the call. Logging, caching, response shaping, timeouts.
- Filter — turn a thrown exception into a response.
Putting logic in the wrong one is a common source of surprise: a pipe cannot reject on authorisation grounds cleanly, and a guard cannot see the parsed body.
Common questions#
Guard or middleware?#
Guards know about the route — they get the handler, the class and its metadata, so they can read decorators like @Roles(). Middleware runs before routing and knows only the request. Use guards for anything decision-based.
Where do I check that a user owns a record?#
In the service, where the record is fetched. A guard runs before the handler and would have to query the database itself, duplicating work and drifting out of sync with the real query.
403 or 404 for a resource the user cannot access?#
404, for anything with a guessable or enumerable id. A 403 confirms the resource exists, which is an information leak. Use 403 when the caller is allowed to know it exists but not to act.
Get the NestJS agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.