Skip to content

nest_11_auth - Authentication & Passport

Auth is where most APIs die Not because implementing it is hard , but because doing it right means understanding JWTs , session management , token refresh flows , and the 17 ways attackers bypass your half-assed implementation NestJS wraps Passport.js with decorators and guards - use it or spend your next deployment debugging why your tokens work in Postman but not the browser

what's in here

  • @nestjs/passport and the Passport module
  • JWT strategy implementation
  • Local strategy (username/password)
  • Session-based auth with Passport
  • OAuth2 / social login (Google , GitHub)
  • Protected routes with @UseGuards(JwtAuthGuard)
  • Refresh token flow

the Passport module setup

npm install @nestjs/passport passport passport-jwt @nestjs/jwt
npm install --save-dev @types/passport-jwt
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { JwtStrategy } from './strategies/jwt.strategy'

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: process.env.JWT_SECRET,  // store in env , not in code
      signOptions: {
        expiresIn: '15m'  // short-lived access tokens
      }
    })
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService]
})
export class AuthModule {}

JwtModule.register() configures the JWT secret and signing options PassportModule.register() sets the default strategy to jwt The secret comes from environment variables - hardcoding it means a git leak is also an auth bypass

JWT strategy

import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { UsersService } from '../../users/users.service'

interface JwtPayload {
  sub: string     // user ID
  email: string
  roles: string[]
  iat?: number
  exp?: number
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private usersService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,   // reject expired tokens
      secretOrKey: process.env.JWT_SECRET
    })
  }

  async validate(payload: JwtPayload): Promise<any> {
    // every request with a valid JWT calls this
    const user = await this.usersService.findById(payload.sub)

    if (!user) {
      throw new UnauthorizedException('user no longer exists')
    }

    // check if token was issued before password change
    if (user.passwordChangedAt && payload.iat) {
      const changedAt = Math.floor(user.passwordChangedAt.getTime() / 1000)
      if (payload.iat < changedAt) {
        throw new UnauthorizedException('token is no longer valid - password changed')
      }
    }

    // this object becomes request.user
    return {
      id: user.id,
      email: user.email,
      roles: user.roles
    }
  }
}

PassportStrategy(Strategy) wraps Passport's JWT strategy validate() is called for every request with a valid JWT - this is where you check if the user still exists , if their password was changed , if they're suspended The return value of validate() becomes request.user - accessible in controllers with @Req().user or a custom @CurrentUser() decorator

login endpoint (issuing tokens)

import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'
import { AuthService } from './auth.service'

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

  @Post('login')
  @HttpCode(HttpStatus.OK)
  async login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto.email, loginDto.password)
  }

  @Post('register')
  async register(@Body() registerDto: CreateUserDto) {
    return this.authService.register(registerDto)
  }

  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  async refresh(@Body() refreshDto: { refreshToken: string }) {
    return this.authService.refreshTokens(refreshDto.refreshToken)
  }

  @Post('logout')
  @HttpCode(HttpStatus.NO_CONTENT)
  async logout(@Body() logoutDto: { refreshToken: string }) {
    await this.authService.logout(logoutDto.refreshToken)
  }
}

Login validates credentials and returns tokens Register creates a new user account Refresh issues new access tokens without re-authentication Logout invalidates the refresh token server-side

auth service with tokens

import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import * as bcrypt from 'bcrypt'
import { UsersService } from '../users/users.service'

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private usersService: UsersService
  ) {}

  async login(email: string, password: string) {
    const user = await this.usersService.findByEmail(email)
    if (!user) {
      // don't reveal whether the email exists
      throw new UnauthorizedException('invalid credentials')
    }

    const passwordValid = await bcrypt.compare(password, user.passwordHash)
    if (!passwordValid) {
      throw new UnauthorizedException('invalid credentials')
    }

    return this.generateTokens(user)
  }

  async register(dto: CreateUserDto) {
    const existing = await this.usersService.findByEmail(dto.email)
    if (existing) {
      throw new ConflictException('email already registered')
    }

    const passwordHash = await bcrypt.hash(dto.password, 12)  // bcrypt cost factor
    const user = await this.usersService.create({
      ...dto,
      passwordHash
    })

    return this.generateTokens(user)
  }

  private generateTokens(user: any) {
    const payload = {
      sub: user.id,
      email: user.email,
      roles: user.roles || ['user']
    }

    const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' })
    const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' })

    return {
      accessToken,
      refreshToken,
      expiresIn: 900,  // 15 minutes in seconds
      user: {
        id: user.id,
        email: user.email,
        roles: user.roles
      }
    }
  }

  async refreshTokens(refreshToken: string) {
    try {
      const payload = await this.jwtService.verifyAsync(refreshToken)
      const user = await this.usersService.findById(payload.sub)
      if (!user) {
        throw new UnauthorizedException('user not found')
      }
      return this.generateTokens(user)
    } catch {
      throw new UnauthorizedException('invalid or expired refresh token')
    }
  }

  async logout(refreshToken: string) {
    // add to token blacklist in Redis/database
    await this.usersService.blacklistToken(refreshToken)
  }
}

Access tokens are short-lived (15 minutes) - limits damage if stolen Refresh tokens are long-lived (7 days) but can be revoked Password hashing uses bcrypt with cost factor 12 - SHA256 is not for passwords "Invalid credentials" for both wrong email AND wrong password - don't tell attackers which field they guessed right

protecting routes with auth guard

import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

// usage
@Controller('users')
@UseGuards(JwtAuthGuard)  // all routes in this controller require auth
export class UsersController {
  @Get('profile')
  getProfile(@CurrentUser() user: User) {
    return user  // from JwtStrategy.validate() -> request.user
  }

  @Get('public')
  @Public()  // custom decorator to skip auth
  getPublic() {
    return 'public info'
  }
}

AuthGuard('jwt') is Passport's guard that runs the JWT strategy on each request Combine with a @Public() decorator for routes that don't need auth

OAuth2 / social login

npm install @nestjs/passport passport-google-oauth20
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { Strategy, VerifyCallback } from 'passport-google-oauth20'

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor() {
    super({
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: 'http://localhost:3000/auth/google/redirect',
      scope: ['email', 'profile']
    })
  }

  async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback) {
    const { name, emails, photos } = profile
    const user = {
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      picture: photos[0].value,
      accessToken
    }
    done(null, user)
  }
}
@Controller('auth')
export class AuthController {
  @Get('google')
  @UseGuards(AuthGuard('google'))
  async googleAuth() {}

  @Get('google/redirect')
  @UseGuards(AuthGuard('google'))
  async googleAuthRedirect(@Req() req, @Res() res) {
    // user is in req.user after validate() completes
    const tokens = await this.authService.generateTokens(req.user)
    res.redirect(`http://localhost:3000/dashboard?token=${tokens.accessToken}`)
  }
}

OAuth2 redirects the user to Google , they authenticate , and Google redirects back with a profile The strategy's validate() method creates or finds the user in your database After social auth , issue your own JWT tokens for subsequent API calls

security patterns for auth

  • Rate limit login endpoints - prevent brute force , /auth/login should have a throttle guard (see nest_12_security)
  • Token rotation on password change - force re-login when password changes
  • Short-lived access tokens - 15 minutes max , forces regular re-authentication
  • Refresh token rotation - issue a new refresh token with each rotation , invalidate the old one
  • Audit log auth events - log every login , logout , failed attempt , and token refresh
  • Secure cookies for browser apps - httpOnly , SameSite=Strict , Secure flag in production

prerequisites

nest_09_pipes - Pipes & Validation


next -> nest_12_security - Security Best Practices