Back to Blog

NestJS Guards & Authorization in Practice

NestJS Guards & Authorization in Practice
nestjstypescriptguards

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:

  1. The guard invokes the JwtStrategy

  2. The strategy validates the token using your secret

  3. The validate() method returns a user object

  4. That 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();     // 403

Swagger 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.