How to implement authentication with Passport in NestJS?
NestJS Passport authentication - a pattern where strategy classes (JWT, local, OAuth2) contain validation logic, and guards decide which routes require it.
Theory
TL;DR
- Passport works like airport security: each strategy is a separate checkpoint (ID check, token scanner), guards open or block the gate
- Strategies answer how to validate (verify JWT signature, compare bcrypt hash); guards answer when to run them
- Use local strategy only at the login endpoint; use JWT strategy for every other protected route
- Four packages:
@nestjs/passport,passport-local,passport-jwt,@nestjs/jwt - Missing
PassportModulein imports is the most common setup failure
Quick setup
Install the packages:
npm install @nestjs/passport passport passport-local passport-jwt @nestjs/jwt
npm install --save-dev @types/passport-local @types/passport-jwtThen wire up the module:
// auth.module.ts
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1h' },
}),
UsersModule,
],
providers: [AuthService, JwtStrategy, LocalStrategy],
controllers: [AuthController],
})
export class AuthModule {}PassportModule binds strategies to the Express layer. Without it, AuthGuard('jwt') fails at runtime with no clear error.
How strategies and guards connect
A strategy is a provider class that extends PassportStrategy(Strategy) and implements validate(). When a request hits a guarded route, AuthGuard('jwt') calls passport.authenticate('jwt') internally. Passport finds the registered strategy, verifies the token, and attaches the result to req.user. The guard triggers the strategy. The strategy does the work. That separation is what makes swapping strategies later painless.
Local strategy (login endpoint only)
// local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email' }); // override default 'username' field
}
async validate(email: string, password: string): Promise<any> {
const user = await this.authService.validateUser(email, password);
if (!user) throw new UnauthorizedException('Invalid credentials');
return user; // attached to req.user
}
}The service-level check uses bcrypt:
// auth.service.ts (validateUser)
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (user && await bcrypt.compare(password, user.password)) {
const { password, ...result } = user;
return result; // never return the raw password field
}
return null;
}JWT strategy (protected routes)
After login the client stores the token and sends it as a Bearer header on every subsequent request. The JWT strategy verifies the signature and extracts the payload:
// jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // expired tokens return 401 automatically
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: { sub: string; email: string }) {
return { id: payload.sub, email: payload.email }; // becomes req.user
}
}ignoreExpiration: false is the correct default. Setting it to true in development and forgetting to revert it is a real security hole.
Auth service: issuing tokens
async login(user: any) {
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwtService.sign(payload),
refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}Controller: tying it together
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() req) {
return this.authService.login(req.user); // req.user set by LocalStrategy
}
@UseGuards(AuthGuard('jwt'))
@Get('profile')
getProfile(@Request() req) {
return req.user; // set by JwtStrategy
}
}Global JWT guard with a @Public() escape hatch
Annotating every route with @UseGuards(AuthGuard('jwt')) gets repetitive. A better pattern: apply the guard globally and mark open routes with a @Public() decorator.
// jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}Register it in AppModule:
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}Now every route is protected by default. Add @Public() to skip the check for login, health, or any open endpoint.
When to use which strategy
- Stateless API (mobile or SPA clients): JWT strategy, no server sessions needed
- Login endpoint: local strategy to verify credentials, then issue JWT immediately after
- Social login:
passport-google-oauth20or similar OAuth2 strategy - Internal microservices: custom strategy with API key extraction
- Simple session-based web app with no API: skip Passport, use
@nestjs/sessiondirectly
Common mistakes
Forgetting PassportModule in imports. Strategies register through it. Without the import, guards fail at runtime and the error message points nowhere useful.
// Wrong
@Module({ imports: [JwtModule.register({...})] })
// Fix
@Module({ imports: [PassportModule, JwtModule.register({...})] })Hardcoding the JWT secret. Any git log exposes it. Every token ever signed becomes forgeable.
// Wrong
secretOrKey: 'my-secret'
// Fix
secretOrKey: process.env.JWT_SECRETvalidate() returning null without throwing. Passport treats a null return as auth failure, but if you do not throw UnauthorizedException, the resulting error message is hard to trace.
// Wrong: silent failure path
async validate(payload: any) {
return this.usersService.findById(payload.sub); // might return null
}
// Fix
async validate(payload: any) {
const user = await this.usersService.findById(payload.sub);
if (!user) throw new UnauthorizedException();
return user;
}Setting ignoreExpiration: true. Works fine locally. In production, expired tokens pass as valid indefinitely.
Using local strategy for regular API requests. Local strategy reads credentials from the request body. It belongs only at the login endpoint. For everything else, use JWT.
Request flow at a glance
POST /auth/login
Body: { email, password }
-> AuthGuard('local') triggers LocalStrategy.validate()
-> AuthService.validateUser() checks bcrypt hash
-> Returns { access_token, refresh_token }
GET /api/profile
Header: Authorization: Bearer <token>
-> AuthGuard('jwt') triggers JwtStrategy.validate()
-> JWT signature verified, payload decoded
-> req.user populated -> controller runs
-> 401 if token missing, expired, or tamperedReal-world usage
- E-commerce APIs (similar to MedusaJS patterns): JWT for cart and order endpoints, local strategy only at admin login
- Prisma + NestJS starters: custom JWT strategy injected into GraphQL context
- NestJS API gateway: API key strategy between internal microservices
- SaaS dashboards:
passport-google-oauth20for social login, JWT for session after OAuth callback
For JWT logout: stateless tokens cannot be revoked by design. The production pattern is to add a jti (JWT ID) claim, store revoked IDs in Redis with the token's remaining TTL, and check the blacklist inside JwtStrategy.validate().
Follow-up questions
Q: Walk me through what happens when a request hits a @UseGuards(AuthGuard('jwt')) route.
A: The guard calls canActivate(), which calls passport.authenticate('jwt'). Passport finds the registered JwtStrategy, extracts the Bearer token, verifies the signature against secretOrKey, and if valid calls validate(payload). The return value becomes req.user. If anything fails, Passport returns 401 before the controller runs.
Q: What is the difference between AuthGuard('jwt') and a class that extends AuthGuard('jwt')?
A: The string version calls the strategy by name with no customization. Extending AuthGuard('jwt') lets you override canActivate() or handleRequest() to add logging, throttling, role checks, or the @Public() reflector pattern without touching controller code.
Q: How do you implement JWT logout?
A: JWT tokens are stateless, so the server cannot invalidate them directly. Add a jti claim when signing, store revoked jti values in Redis with the token's remaining TTL, and reject matching tokens in validate(). This is the standard blacklist approach.
Q: Why use @nestjs/passport over raw @nestjs/jwt?
A: @nestjs/jwt signs and verifies tokens. Passport adds the request integration layer: it extracts the token, calls validate(), and sets req.user. It also gives you one consistent interface across multiple strategies (local, JWT, OAuth2) without any changes to controller code.
Q: Senior: how would you handle concurrent logins across devices and invalidate sessions on password change?
A: Embed a version field from the user record into the JWT payload. On password change, increment the version in the database. In JwtStrategy.validate(), load the user and compare versions. If the token's version does not match, throw UnauthorizedException. This invalidates all existing tokens without a blacklist.
Examples
Basic: login and protected profile endpoint
The minimal working setup. User logs in with email and password, receives a JWT, uses it on protected endpoints.
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() req) {
// req.user comes from LocalStrategy.validate() after bcrypt check
return this.authService.login(req.user);
// Output: { access_token: 'eyJ...', refresh_token: 'eyJ...' }
}
@UseGuards(AuthGuard('jwt'))
@Get('profile')
getProfile(@Request() req) {
return req.user;
// Output: { id: '123', email: 'user@example.com' }
// Without valid Bearer token: 401 Unauthorized
}
}On login, LocalStrategy calls validateUser(), which compares the bcrypt hash. On success, AuthService.login() signs a JWT with sub (user ID) and email. On the profile request, JwtStrategy decodes that payload and returns it as req.user.
Intermediate: role-based access with a custom guard
After JWT verification you often need to check the user's role. handleRequest() runs after Passport validation and is the right place for that:
// admin.guard.ts
@Injectable()
export class AdminGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any) {
if (err || !user) throw err || new UnauthorizedException();
if (!user.roles?.includes('admin')) {
throw new ForbiddenException('Admin access required');
}
return user;
}
}
// controller
@UseGuards(AdminGuard)
@Get('admin/dashboard')
getDashboard(@Request() req) {
return { user: req.user, data: 'admin content' };
// 401 if no token, 403 if token valid but role is not 'admin'
}This keeps role logic out of the controller and out of the strategy. Each layer has one job.
Advanced: refresh token strategy with a named strategy and custom extractor
Access tokens expire in minutes. Refresh tokens live longer and rotate on use. A separate named Passport strategy handles this cleanly:
// refresh.strategy.ts
@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
// 'refresh' is the strategy name for AuthGuard('refresh')
constructor() {
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'), // reads from POST body
ignoreExpiration: false,
secretOrKey: process.env.REFRESH_SECRET, // different secret than access token
});
}
async validate(payload: any) {
// Production: check Redis blacklist by payload.jti here
return { userId: payload.sub };
}
}
// controller
@UseGuards(AuthGuard('refresh'))
@Post('auth/refresh')
async refresh(@Request() req) {
// req.user = { userId: '...' } from RefreshStrategy.validate()
return this.authService.rotateTokens(req.user.userId);
// Output: { access_token: '...', refresh_token: '...' (new, old invalidated) }
}The second argument to PassportStrategy(Strategy, 'refresh') is critical. Without a unique name, the second strategy overwrites the first and AuthGuard('jwt') stops working.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.