NestJS Guards & Authorization in Practice

Guards are NestJS's gatekeepers – they decide whether a request reaches your controller or gets turned away. In this post, I'll walk through how I implemented a pragmatic authorization system using three guards: ThrottlerGuard for rate limiting, AuthGuard for authentication, and a custom AdminGuard for role-based access.
The Guard Pipeline
Every request flows through guards in order before reaching your controller:
┌────────────────────────────────────────────────────────────────┐
│ Request │
└──────────────────────────┬─────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ ThrottlerGuard │ Brute-force protection (10 req/min)│
└──────────────────────────┬─────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ AuthGuard('jwt') │ Is there a valid token? │
└──────────────────────────┬─────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ AdminGuard │ Is the user an admin? │
└──────────────────────────┬─────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ Controller │
└────────────────────────────────────────────────────────────────┘What Are Guards?
At their core, guards implement a simple interface:
typescript
interface CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean>;
}The return value determines what happens next: true lets the request through, false automatically returns 403 Forbidden, and throwing an exception gives you control over the error code (like 401 for authentication failures).
ThrottlerGuard: Rate Limiting
Rate limiting is your first line of defense against brute-force attacks. The ThrottlerGuard from @nestjs/throttler makes this straightforward.
First, configure it in your app module:
typescript
// app.module.ts
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([{
ttl: 60000, // Time window: 60 seconds
limit: 10, // Max 10 requests per minute
}]),
],
})
export class AppModule {}Then apply it to sensitive routes like login:
typescript
// auth.controller.ts
import { ThrottlerGuard } from '@nestjs/throttler';
@Post('login')
@UseGuards(ThrottlerGuard)
async login(@Body() dto: LoginDto) {
return this.auth.login(dto.email, dto.password);
}After 10 failed login attempts, an attacker has to wait 60 seconds before trying again. When the limit is hit, the client receives a 429 Too Many Requests response.
AuthGuard: JWT Authentication
Passport's built-in AuthGuard handles the question: "Is there a valid JWT present?"
typescript
// settings.controller.ts
import { AuthGuard } from '@nestjs/passport';
@Post('change-password')
@UseGuards(AuthGuard('jwt'))
changePassword(
@Request() req: Request & { user: { id: string } },
@Body() dto: ChangePasswordDto,
) {
// req.user comes from JwtStrategy.validate()
return this.settingsService.changePassword(req.user.id, dto);
}Here's what happens under the hood when a request hits this route:
The guard invokes the JwtStrategy
The strategy validates the token using your secret
The
validate()method returns a user objectThat user object lands in
req.user
The strategy ties it all together:
typescript
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(cfg: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: cfg.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
validate(payload: { sub: string; email: string; isAdmin: boolean }) {
// This is what ends up in req.user
return { id: payload.sub, email: payload.email, isAdmin: payload.isAdmin };
}
}When authentication fails, the response is 401 Unauthorized.
AdminGuard: Our Custom Guard
For admin-only routes, I built a custom guard that combines JWT validation with an admin check:
typescript
// auth/guards/admin.guard.ts
import {
Injectable,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class AdminGuard extends AuthGuard('jwt') {
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. Check JWT (parent class)
const isAuthenticated = await super.canActivate(context);
if (!isAuthenticated) {
throw new UnauthorizedException('Authentication required');
}
// 2. Check admin status
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.isAdmin) {
throw new ForbiddenException('Admin access required');
}
return true;
}
}Why extend AuthGuard instead of stacking two separate guards? It's cleaner:
typescript
// ❌ Two separate guards - more boilerplate
@UseGuards(AuthGuard('jwt'), AdminCheckGuard)
// ✅ One combined guard - cleaner
@UseGuards(AdminGuard)The AdminGuard returns different status codes depending on the failure: 401 Unauthorized for missing or invalid tokens, 403 Forbidden for valid tokens without admin privileges.
The Three Route Types
In practice, I organize routes into three categories.
Public routes have no guards – anyone can access them:
typescript
@Controller('projects')
export class ProjectsController {
@Get() // Anyone can see the list
list(@Query() filters: ProjectFilterDto) {
return this.svc.findAll(filters);
}
@Get(':id') // Anyone can see details
get(@Param('id') id: string) {
return this.svc.findOne(id);
}
}Auth-only routes require a logged-in user:
typescript
@Controller('settings')
export class SettingsController {
@Get() // Public: read site settings
getSettings() {
return this.settingsService.getSettings();
}
@Post('change-password')
@UseGuards(AuthGuard('jwt')) // Logged-in users only
changePassword(@Request() req, @Body() dto: ChangePasswordDto) {
return this.settingsService.changePassword(req.user.id, dto);
}
@Post('change-email')
@UseGuards(AuthGuard('jwt')) // Logged-in users only
changeEmail(@Request() req, @Body() dto: ChangeEmailDto) {
return this.settingsService.changeEmail(req.user.id, dto);
}
}Admin-only routes protect write operations:
typescript
@Controller('projects')
export class ProjectsController {
// Public
@Get()
list() { ... }
@Get(':id')
get() { ... }
// Admin only
@Post()
@UseGuards(AdminGuard)
@ApiBearerAuth('JWT-auth')
create(@Body() dto: CreateProjectDto) {
return this.svc.create(dto);
}
@Patch(':id')
@UseGuards(AdminGuard)
@ApiBearerAuth('JWT-auth')
update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
return this.svc.update(id, dto);
}
@Delete(':id')
@UseGuards(AdminGuard)
@ApiBearerAuth('JWT-auth')
remove(@Param('id') id: string) {
return this.svc.remove(id);
}
}Understanding ExecutionContext
Inside a guard, you have access to everything about the request through the ExecutionContext:
typescript
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// Available after AuthGuard runs:
const user = request.user; // { id, email, isAdmin }
// Always available:
const authHeader = request.headers.authorization;
const method = request.method; // GET, POST, etc.
const url = request.url; // /projects/123
const params = request.params; // { id: '123' }
const body = request.body; // POST/PATCH body
return true;
}HTTP Status Codes
Getting status codes right matters for API consumers:
CodeMeaningWhen to use401UnauthorizedNo token or invalid token403ForbiddenValid token but insufficient permissions429Too Many RequestsRate limit exceeded
Use the built-in exceptions:
typescript
throw new UnauthorizedException(); // 401
throw new ForbiddenException(); // 403Swagger Integration
Document your protected routes properly:
typescript
@Post()
@UseGuards(AdminGuard)
@ApiBearerAuth('JWT-auth') // 🔒 icon in Swagger UI
@ApiResponse({ status: 201, description: 'Created' })
@ApiResponse({ status: 401, description: 'Unauthorized - JWT required' })
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
create(@Body() dto: CreateProjectDto) { ... }Summary
For a single-admin portfolio app, this setup hits the sweet spot between security and simplicity:
┌─────────────────────────────────────────────────────────────┐
│ A Pragmatic Approach (Single-Admin App) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ThrottlerGuard → Login protection → 429 │
│ AuthGuard('jwt') → "Are you logged in?" → 401 │
│ AdminGuard → "Are you an admin?" → 401 or 403 │
│ │
├─────────────────────────────────────────────────────────────┤
│ No @Roles() decorators, no global guards │
│ → Simple, because we only have one admin │
└─────────────────────────────────────────────────────────────┘No complex role hierarchies, no elaborate permission systems – just three guards that cover all the use cases. For larger applications with multiple roles, you'd want to look into solutions like CASL or custom role decorators. But for a portfolio backend where you're the only admin? This is all you need.