LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
Lesson 925 min read

Guards & JWT Authentication

Protect NestJS routes with guards: implement JWT login with @nestjs/jwt, an auth guard, a public-route decorator and role-based authorization.

On this page 13 sections

Introduction

Most APIs have routes that only certain users may reach: your own profile, your orders, the admin dashboard. In NestJS, the component responsible for "may this request continue?" is the guard. In this lesson you will use guards to build complete JWT (JSON Web Token) authentication plus role-based authorization.

What You Will Learn
  • What a guard is and where it runs in the request lifecycle.
  • How token-based (JWT) authentication works.
  • How to issue tokens with @nestjs/jwt after checking a hashed password.
  • How to write an AuthGuard that verifies tokens.
  • How to mark public routes and restrict routes by role.

What is a Guard?

A guard is a class that implements CanActivate. Its canActivate() method returns true (let the request through) or false (block it with 403 Forbidden). Guards run after middleware but before pipes and the route handler, and — unlike middleware — they know exactly which handler is about to run, so they can read its metadata.

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.headers['x-api-key'] === process.env.API_KEY;
}
}

How JWT Authentication Works

  1. The client sends email and password to POST /auth/login.
  2. The server checks the password against the stored hash and, if it matches, signs a JWT containing the user's id (and maybe role).
  3. The client stores the token and sends it on every later request in the header Authorization: Bearer <token>.
  4. A guard verifies the token's signature and expiry; if valid, it attaches the user info to the request and lets it through.
JWTs Are Signed, Not Encrypted

Anyone can decode a JWT and read its payload. Never put passwords or other secrets inside it — just identifiers like the user id and role.

Setting Up the Auth Module

npm install @nestjs/jwt bcrypt
npm install -D @types/bcrypt
npm install-scripts approve bcrypt # allow bcrypt's native build step (npm 12+)
npm rebuild bcrypt
nest g module auth
nest g controller auth
nest g service auth
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module.js';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
@Module({
imports: [
UsersModule, // UsersModule must export UsersService
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1h' },
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
Secrets Come From the Environment

Never hard-code the JWT secret. Lesson 11 shows how to load it safely with @nestjs/config and JwtModule.registerAsync().

Signing In and Issuing Tokens

Passwords must be stored as hashes, never as plain text. bcrypt.hash() creates a hash when a user registers; bcrypt.compare() checks a login attempt against it:

// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service.js';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async signIn(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
const valid = user && (await bcrypt.compare(password, user.passwordHash));
if (!valid) {
throw new UnauthorizedException('Invalid email or password');
}
const payload = { sub: user.id, email: user.email, role: user.role };
return { access_token: await this.jwtService.signAsync(payload) };
}
}
// src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public() // defined below — login must be reachable without a token
@Post('login')
@HttpCode(HttpStatus.OK)
signIn(@Body() dto: SignInDto) {
return this.authService.signIn(dto.email, dto.password);
}
}
POST /auth/login

Click Run to see what this code prints.

The Auth Guard

// src/auth/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from './public.decorator.js';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService, private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException();
}
try {
request['user'] = await this.jwtService.verifyAsync(token);
} catch {
throw new UnauthorizedException();
}
return true;
}
}

verifyAsync() checks both the signature and the expiry. On success, the decoded payload is attached as request.user, so handlers can read who is calling:

@Get('profile')
getProfile(@Req() req) {
return req.user; // { sub: 1, email: 'priya@example.com', role: 'user', iat: ..., exp: ... }
}

Public Routes & a Global Guard

Instead of adding @UseGuards(AuthGuard) to every controller, it is safer to protect everything by default and explicitly mark the few public routes. First, a custom decorator that attaches metadata:

// src/auth/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

Then register AuthGuard globally using the APP_GUARD token, so it can still use dependency injection:

// src/auth/auth.module.ts
import { APP_GUARD } from '@nestjs/core';
@Module({
// ...imports, controllers as before
providers: [
AuthService,
{ provide: APP_GUARD, useClass: AuthGuard },
],
})
export class AuthModule {}

Now every route requires a valid token unless it carries @Public(). The guard reads that metadata with Reflector.getAllAndOverride(), checking the handler first, then the controller.

Role-Based Authorization

Authentication answers "who are you?"; authorization answers "what may you do?". A second guard can check the role inside the token:

// src/auth/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// src/auth/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.includes(user?.role);
}
}
@Delete(':id')
@Roles('admin')
@UseGuards(RolesGuard)
remove(@Param('id', ParseIntPipe) id: number) {
return this.productsService.remove(id);
}

Global guards run before controller-level and route-level guards, so the global AuthGuard has already set request.user by the time RolesGuard checks it. A regular user calling this route gets 403 Forbidden.

What About Passport?

Nest also offers @nestjs/passport, which wraps the Passport.js library and its many strategies (local, JWT, Google, GitHub OAuth and more). The hand-written guard above is the approach shown in the official docs' authentication chapter and teaches the underlying mechanics; Passport is a great choice when you need social logins.

Common Mistakes

Avoid These Mistakes
  • Storing plain-text passwords instead of bcrypt hashes.
  • Returning different errors for "unknown email" and "wrong password" — this lets attackers discover which emails are registered.
  • Hard-coding the JWT secret in source code.
  • Forgetting @Public() on the login route after enabling a global guard — no one can log in.
  • Putting sensitive data inside the JWT payload.

Best Practices

  • Protect every route by default with a global guard, and opt out with @Public().
  • Keep access tokens short-lived (minutes to an hour).
  • Load secrets from environment variables, never from code.
  • Separate authentication (AuthGuard) from authorization (RolesGuard).
  • Serve your API over HTTPS in production so tokens cannot be intercepted.

Frequently Asked Questions

401 Unauthorized means "we don't know who you are" (no or invalid token). 403 Forbidden means "we know who you are, but you are not allowed to do this".

JWTs are stateless, so the simplest logout is deleting the token on the client. For server-side revocation, use short-lived access tokens plus refresh tokens stored (and revocable) in a database.

Yes — createParamDecorator() lets you build a custom @CurrentUser() decorator that returns request.user directly.

Key Takeaways

  • Guards implement CanActivate and decide whether a request may proceed.
  • @nestjs/jwt signs tokens on login and verifies them in an AuthGuard.
  • Passwords are stored as bcrypt hashes and checked with bcrypt.compare().
  • APP_GUARD plus a @Public() decorator protects all routes by default.
  • A RolesGuard with @Roles() metadata handles role-based authorization.

Summary

Your API now has real authentication and authorization. Next, you will meet interceptors — the tool for wrapping handlers with extra behavior like timing, response transformation and serialization — and see the full order in which every piece of the request lifecycle runs.

Next Lesson →

Interceptors & the Request Lifecycle