Blog Post
8 min read

NestJS Authentication & Authorization: The Complete Mental Model

I'll teach you this using analogies you'll never forget + production-ready code. Let's build your understanding from the ground up.

Published on November 11, 2025

🎯 The Core Mental Model

Authentication = "Who are you?" (Proving identity)
Authorization = "What can you do?" (Checking permissions)

Think of it like a hotel:

  • Authentication: Showing your ID at check-in → You get a key card (JWT token)

  • Authorization: Your key card only opens your room, not the penthouse → Role-based access

📚 The 3-Layer Architecture (Never Forget This!)

┌─────────────────────────────────────┐
│  1. GUARDS (Bouncers)               │ ← "Can you enter?"
│     - Check if token exists/valid   │
│     - Extract user from token       │
└─────────────────────────────────────┘
            ↓
┌─────────────────────────────────────┐
│  2. STRATEGIES (ID Checkers)        │ ← "How do we verify you?"
│     - JWT Strategy                  │
│     - Local Strategy (login)        │
└─────────────────────────────────────┘
            ↓
┌─────────────────────────────────────┐
│  3. DECORATORS (Smart Labels)       │ ← "Who can use this?"
│     - @Public()                     │
│     - @Roles('admin')               │
└─────────────────────────────────────┘


🛠️ Step-by-Step Implementation

Step 1: Install Dependencies
npm install @nestjs/passport @nestjs/jwt passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt

Step 2: Auth Module Structure
src/
├── auth/
│   ├── auth.module.ts
│   ├── auth.service.ts
│   ├── auth.controller.ts
│   ├── strategies/
│   │   ├── jwt.strategy.ts
│   │   └── local.strategy.ts
│   ├── guards/
│   │   ├── jwt-auth.guard.ts
│   │   └── roles.guard.ts
│   └── decorators/
│       ├── public.decorator.ts
│       └── roles.decorator.ts

Step 3: User Entity (Prisma Example)

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String   // HASHED!
  role      Role     @default(USER)
  createdAt DateTime @default(now())
}

enum Role {
  USER
  ADMIN
  MODERATOR
}

Step 4: Auth Service - The Brain

// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}

  // 🔐 REGISTRATION: Hash password + Save user
  async register(email: string, password: string) {
    const hashedPassword = await bcrypt.hash(password, 10);
    
    const user = await this.prisma.user.create({
      data: {
        email,
        password: hashedPassword,
      },
    });

    // Remove password from response
    const { password: _, ...result } = user;
    return result;
  }

  // 🔑 LOGIN: Validate credentials + Generate token
  async validateUser(email: string, password: string) {
    const user = await this.prisma.user.findUnique({ where: { email } });
    
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);
    
    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const { password: _, ...result } = user;
    return result;
  }

  // 🎫 GENERATE TOKEN: Create JWT
  async login(user: any) {
    const payload = { 
      sub: user.id,      // "sub" is JWT standard for user ID
      email: user.email,
      role: user.role 
    };

    return {
      access_token: this.jwtService.sign(payload),
      user,
    };
  }
}

Step 5: Local Strategy - Login Validation

// strategies/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super({
      usernameField: 'email', // Default is 'username', we use 'email'
    });
  }

  // This runs when login endpoint is hit
  async validate(email: string, password: string) {
    return await this.authService.validateUser(email, password);
  }

}
💡 Mental Model: Local Strategy = "Check email/password at login time"


// Step 6: JWT Strategy - Token Validation
// strategies/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET, // Store in .env!
    });
  }

  // This runs on EVERY protected route
  // Payload = decoded JWT data
  async validate(payload: any) {
    // This object is attached to request.user
    return { 
      id: payload.sub, 
      email: payload.email, 
      role: payload.role 
    };
  }
}

💡 Mental Model: JWT Strategy = "Decode token on every request, attach user to req.user"

Step 7: Guards - The Bouncers

//JWT Auth Guard (Default Protected)
// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    // Check if route is marked as public
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true; // Skip authentication
    }

    return super.canActivate(context); // Run JWT validation
  }
}
//Roles Guard (Check Permissions)

// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // Get required roles from decorator
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) {
      return true; // No roles required
    }

    const { user } = context.switchToHttp().getRequest();
    
    // Check if user has ANY of the required roles
    return requiredRoles.some((role) => user.role === role);
  }
}


Step 8: Decorators - Magic Labels

Public Decorator

// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

Roles Decorator

// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

Current User Decorator (Bonus!)

// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user; // Extracted by JWT strategy
  },
);

Step 9: Auth Module - Wire Everything

// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PrismaService } from '../prisma/prisma.service';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '7d' }, // Token expires in 7 days
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy, PrismaService],
  exports: [AuthService], // Export for use in other modules
})
export class AuthModule {}

Step 10: Auth Controller - API Endpoints

// auth.controller.ts
import { Controller, Post, Body, UseGuards, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  // Public route - No authentication needed
  @Public()
  @Post('register')
  async register(@Body() body: { email: string; password: string }) {
    return this.authService.register(body.email, body.password);
  }

  // Uses LocalStrategy to validate email/password
  @Public()
  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@CurrentUser() user: any) {
    return this.authService.login(user);
  }

  // Protected route - JWT required (global guard)
  @Get('profile')
  getProfile(@CurrentUser() user: any) {
    return user;
  }
}

Local Auth Guard (Simple wrapper)

// guards/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

Step 11: App Module - Global Protection

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';

@Module({
  imports: [AuthModule],
  providers: [
    // Make JWT guard global - all routes protected by default
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
    // Apply roles guard globally (runs after JWT guard)
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class AppModule {}

🔥 This is the magic: All routes are now protected unless marked @Public()!


🎨 Usage Examples

Example 1: Public Route

@Public()
@Get('products')
getAllProducts() {
  return this.productsService.findAll();
}

Example 2: Protected Route (Any logged-in user)

@Get('my-orders')
getMyOrders(@CurrentUser() user: any) {
  return this.ordersService.findByUserId(user.id);
}

Example 3: Admin-Only Route

@Roles('ADMIN')
@Delete('users/:id')
deleteUser(@Param('id') id: string) {
  return this.usersService.delete(id);
}

Example 4: Multi-Role Access

@Roles('ADMIN', 'MODERATOR')
@Post('posts/:id/approve')
approvePost(@Param('id') id: string) {
  return this.postsService.approve(id);
}

🔐 Environment Variables (.env)

env

JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRATION=7d
DATABASE_URL=postgresql://user:password@localhost:5432/mydb

⚠️ CRITICAL: Never commit .env to Git! Use strong, random JWT_SECRET in production.


🚀 Production Best Practices

1. Refresh Tokens (For Long Sessions)

// Add refresh token to User model
model User {
  //... other fields
  refreshToken String?
}

// Generate both tokens
async login(user: any) {
  const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' });
  const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
  
  // Store refresh token hash in DB
  await this.prisma.user.update({
    where: { id: user.id },
    data: { refreshToken: await bcrypt.hash(refreshToken, 10) },
  });

  return { access_token: accessToken, refresh_token: refreshToken };
}

2. Password Requirements

import { IsEmail, IsStrongPassword } from 'class-validator';

export class RegisterDto {
  @IsEmail()
  email: string;

  @IsStrongPassword({
    minLength: 8,
    minLowercase: 1,
    minUppercase: 1,
    minNumbers: 1,
    minSymbols: 1,
  })
  password: string;
}

3. Rate Limiting (Prevent Brute Force)

bash

npm install @nestjs/throttler
// app.module.ts
import { ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([{
      ttl: 60000, // 1 minute
      limit: 10,  // 10 requests per minute
    }]),
  ],
})

4. Logout (Invalidate Token)

@Post('logout')
async logout(@CurrentUser() user: any) {
  // Clear refresh token from DB
  await this.prisma.user.update({
    where: { id: user.id },
    data: { refreshToken: null },
  });
  
  return { message: 'Logged out successfully' };
}

🧠 Mental Checklist (Never Forget!)

Authentication Flow:

  1. User sends email/password → LocalStrategy validates → Generate JWT

  2. User sends JWT in header → JwtStrategy decodes → Attach user to req.user

  3. Guards check if user exists + has correct role

Authorization Flow:

  1. JwtAuthGuard: "Is user logged in?"

  2. RolesGuard: "Does user have required role?"

Security Rules:

  • Hash passwords with bcrypt (never plain text)

  • Use strong JWT_SECRET (random, 32+ characters)

  • Set token expiration (15min access, 7d refresh)

  • Validate all inputs with class-validator

  • Use HTTPS in production

  • Add rate limiting to login/register


📝 Quick Reference Card

// Make route public
@Public()

// Require authentication (default with global guard)
// No decorator needed!

// Require specific role
@Roles('ADMIN')

// Get current user
@CurrentUser() user

// Login flow
LocalStrategy → validate() → AuthService.login() → JWT

// Protected route flow
JWT in header → JwtStrategy.validate() → req.user → Guards check

This architecture is production-ready, scalable, and follows NestJS best practices. The mental models (hotel key cards, bouncers, ID checkers) will stick with you forever! 🎯