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/loginshould 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