A guard is a class with one method, canActivate, that returns true to let the request through or throws to refuse it. Guards run after middleware and before pipes, which is the order Endpoints argued for: an anonymous POST /books is refused before any schema work happens.
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
const [scheme, token] = (req.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token)
throw new UnauthorizedException('Authentication required');
try { // a broken token is 401, not anonymous
const c = this.jwt.verify(token);
req.user = { id: c.sub, roles: c.roles ?? [] }; // roles come from the signature
return true; // never from a header a client controls
} catch (e) { throw new UnauthorizedException((e as Error).message); } } }
@Injectable()
export class RolesGuard implements CanActivate { // runs second, on req.user
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const need = this.reflector.getAllAndOverride<string[]>(ROLES_KEY,
[ctx.getHandler(), ctx.getClass()]); // method decorator beats class decorator
const user = ctx.switchToHttp().getRequest().user;
if (!need?.length || need.some((r) => user?.roles?.includes(r))) return true;
throw new ForbiddenException(`Requires one of: ${need.join(', ')}`); } }Reflector reads metadata a decorator attached to the route: @Roles('editor') is one line, SetMetadata(ROLES_KEY, roles). The order in @UseGuards(JwtAuthGuard, RolesGuard) is the order they run, so a missing token is 401 and a valid token without the role is 403.
$ curl -s -X POST localhost:4310/api/v1/books -d '{}' # no token at all: 401
{"error":{"code":"not_authenticated","message":"Authentication required"},...}
$ curl -s -X POST .../books -H "auth: Bearer $READER" -d '{"title":"X",...}' # 403
{"error":{"code":"forbidden","message":"Requires one of: editor"},"requestId":"22cb.."}The 403 body proves the ordering: the validation pipe never ran, so the body was never parsed. Signing is configured once, in AuthModule, with JwtModule.registerAsync({ inject: [ConfigService], useFactory }) returning { secret: config.getOrThrow('ACCESS_SECRET'), signOptions: { expiresIn: '15m' } }.