Issue a JWT on login, protect routes by validating the token, and let specific routes opt out via
@Public(). The canonical NestJS auth setup.
The four-layer stack (this is the #1 source of confusion)
| Layer | Package | What it does |
|---|---|---|
| Orchestrator | passport | Maintains the registry of named strategies and runs the verify callback |
| Strategy implementation | passport-jwt | Knows how to extract a JWT from a request and verify its signature |
| Nest wrapper | @nestjs/passport | Adapts Passport to Nest: PassportStrategy base class, AuthGuard(name) |
| Your code | JwtStrategy + JwtAuthGuard | Subclass PassportStrategy(Strategy); subclass AuthGuard('jwt') |
AuthGuard('jwt') does not “do JWT” — it asks Passport to run whatever strategy is registered under the name 'jwt'. Your JwtStrategy claims that name (it’s the default for passport-jwt). Swap it for any other strategy and AuthGuard('jwt') would invoke that one instead.
@nestjs/jwt is a separate package: a thin wrapper around jsonwebtoken used to sign tokens at login. The strategy package (passport-jwt) handles verification at request time. Both must be configured with the same secret.
Setup
npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwtLogin endpoint
The login flow is plain controller code: validate credentials, sign a JWT, return it. No Passport guard on this route — Passport’s job starts on the next request.
// auth/auth.module.ts
import { Module } from "@nestjs/common"
import { JwtModule } from "@nestjs/jwt"
import { AuthController } from "./auth.controller"
import { AuthService } from "./auth.service"
import { UsersModule } from "../users/users.module"
import { jwtConstants } from "./constants"
@Module({
imports: [
UsersModule,
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: "60s" },
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}// auth/auth.service.ts
import { Injectable, UnauthorizedException } from "@nestjs/common"
import { JwtService } from "@nestjs/jwt"
import { UsersService } from "../users/users.service"
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async signIn(username: string, pass: string): Promise<{ access_token: string }> {
const user = await this.usersService.findOne(username)
if (user?.password !== pass) {
throw new UnauthorizedException()
}
const payload = { sub: user.userId, username: user.username }
return { access_token: await this.jwtService.signAsync(payload) }
}
}// auth/auth.controller.ts
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common"
import { AuthService } from "./auth.service"
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@HttpCode(HttpStatus.OK)
@Post("login")
signIn(@Body() signInDto: { username: string; password: string }) {
return this.authService.signIn(signInDto.username, signInDto.password)
}
}Request:
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "john", "password": "changeme"}'Response (200 OK):
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }Wrong credentials:
{ "statusCode": 401, "message": "Unauthorized" }The sub claim follows JWT convention (subject of the token) and is conventionally the user id. The default expiresIn: '60s' is intentionally short for the docs example; real apps use minutes for access tokens and pair them with refresh tokens.
JWT strategy
The strategy tells Passport how to extract and verify the token, and what user shape to attach to request.user.
// auth/jwt.strategy.ts
import { Injectable } from "@nestjs/common"
import { PassportStrategy } from "@nestjs/passport"
import { ExtractJwt, Strategy } from "passport-jwt"
import { jwtConstants } from "./constants"
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
})
}
async validate(payload: { sub: number; username: string }) {
return { userId: payload.sub, username: payload.username }
}
}What each option does:
| Option | Purpose |
|---|---|
jwtFromRequest | Where to read the token. fromAuthHeaderAsBearerToken() reads Authorization: Bearer … |
ignoreExpiration | false (default) lets Passport reject expired tokens with 401 automatically |
secretOrKey | Same secret used in JwtModule.register({ secret }) for signing |
validate(payload) is called only after the signature check passes — Passport guarantees the token is authentic. The return value becomes request.user. Throw UnauthorizedException here to reject otherwise-valid tokens (e.g., revoked-token list, banned users).
JwtStrategy.validate()runs after signature verification, not beforeBy the time your
validate()is called, Passport has already verified the signature and decoded the payload. Don’t re-verify the token here — focusvalidate()on application-level checks: revocation list lookup, “is this user still active?”, role enrichment. ThrowUnauthorizedExceptionto reject; throwing anything else still becomes 401 but loses the explicit semantics.
Returning
null/undefinedfromvalidate()producesUnauthorized
@nestjs/passport’s defaulthandleRequestthrowsUnauthorizedExceptionwhenever the strategy returns a falsy value orinfoindicates failure. To return a custom error (e.g.,403 Forbiddenfor inactive users), overridehandleRequest(err, user, info)on the guard. Source: Extending guards.
Register the strategy in AuthModule:
// auth/auth.module.ts (additions)
import { PassportModule } from "@nestjs/passport"
import { JwtStrategy } from "./jwt.strategy"
@Module({
imports: [/* … */ PassportModule],
providers: [/* … */ JwtStrategy],
})
export class AuthModule {}PassportModule must be imported in any module that registers a strategy. Adding JwtStrategy to providers is what binds the class to the name 'jwt' in Passport’s registry.
Protected route
Wrap the strategy in a named guard, then apply it.
// auth/jwt-auth.guard.ts
import { Injectable } from "@nestjs/common"
import { AuthGuard } from "@nestjs/passport"
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}// auth/auth.controller.ts (addition)
import { Controller, Get, Request, UseGuards } from "@nestjs/common"
import { JwtAuthGuard } from "./jwt-auth.guard"
@Controller("auth")
export class AuthController {
@UseGuards(JwtAuthGuard)
@Get("profile")
getProfile(@Request() req: { user: { userId: number; username: string } }) {
return req.user
}
}Why subclass AuthGuard('jwt') instead of using it inline? Two reasons: it removes the magic string from your controllers, and it’s the extension point for @Public(), custom error handling (handleRequest), or chaining strategies.
Request without a token:
curl http://localhost:3000/auth/profileResponse (401 Unauthorized):
{ "statusCode": 401, "message": "Unauthorized" }Request with the token from /auth/login:
curl http://localhost:3000/auth/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Response (200 OK):
{ "userId": 1, "username": "john" }The body matches whatever JwtStrategy.validate() returned — that’s the contract.
Global guard with @Public() opt-out
Once more than a few routes need auth, flip the default: protect everything via [[nestjs/fundamentals/global-providers|APP_GUARD]], then mark the few public routes (login, health checks, signup) with @Public().
// auth/public.decorator.ts
import { SetMetadata } from "@nestjs/common"
export const IS_PUBLIC_KEY = "isPublic"
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)// auth/jwt-auth.guard.ts (replaces the trivial version)
import { ExecutionContext, Injectable } from "@nestjs/common"
import { Reflector } from "@nestjs/core"
import { AuthGuard } from "@nestjs/passport"
import { IS_PUBLIC_KEY } from "./public.decorator"
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private readonly reflector: Reflector) {
super()
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
])
if (isPublic) return true
return super.canActivate(context)
}
}// auth/auth.module.ts (additions)
import { APP_GUARD } from "@nestjs/core"
import { JwtAuthGuard } from "./jwt-auth.guard"
@Module({
providers: [
/* … */
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AuthModule {}// auth/auth.controller.ts (mark login as public)
import { Public } from "./public.decorator"
@Controller("auth")
export class AuthController {
@Public()
@HttpCode(HttpStatus.OK)
@Post("login")
signIn(@Body() signInDto: { username: string; password: string }) {
return this.authService.signIn(signInDto.username, signInDto.password)
}
}Without @Public() on /auth/login, the global guard rejects the login request itself: 401 Unauthorized before signIn runs. Classic chicken-and-egg.
The getAllAndOverride order [handler, class] means a class-level @Public() is overridden by a route-level guard, which matches the convention used elsewhere in the framework (see the guards fundamental).
When to reach for it
- API server with stateless clients (SPA, mobile, server-to-server).
- Most or all endpoints need auth: pair with the global-guard +
@Public()pattern. - Multi-strategy auth where one of the strategies is JWT (
@UseGuards(AuthGuard(['jwt', 'apikey']))).
When not to
- Server-rendered apps with first-party login: a session cookie is simpler and revocable. JWTs are awkward to invalidate.
- Long-lived sessions: short-lived JWT + refresh token is a more involved setup; if the app doesn’t need stateless scale, sessions cost less.
- Trivial single-user / dev tools:
BasicAuthmiddleware is fine, no Passport needed.
Gotchas
The same secret must be in
JwtModuleandJwtStrategySigning happens in
auth.service.tsviaJwtService.signAsync()(configured byJwtModule.register({ secret })). Verification happens inpassport-jwtvia the strategy’ssecretOrKey. Mismatched secrets → every authenticated request gets401 Unauthorizedwith no useful error. Hoist the secret into a singleconstants.ts(orConfigService) and import it in both places.
passport-jwtstrategies cannot beScope.REQUESTPassport registers strategies on a global instance, so request-scoped strategies are never instantiated. If you need per-request data inside
validate(), injectModuleRefand resolve dependencies viaContextIdFactory.getByRequest(request)(also requirespassReqToCallback: truein the strategy options). Source: Request-scoped strategies.
Without
@Public(), the login route also requires a tokenWhen
JwtAuthGuardis the globalAPP_GUARD, every route — including/auth/login— runs through it. Forgetting@Public()on the login handler returns401 Unauthorizedto every client and you’ll think Passport is broken. Add@Public()to login, signup, and any health/status endpoints.
The strategy's default name is
'jwt', override with the secondPassportStrategyarg
PassportStrategy(Strategy, 'myjwt')registers the strategy under the name'myjwt'and you’d then useAuthGuard('myjwt'). Useful when you have multiple JWT strategies (e.g., user tokens vs service tokens with different secrets).
Stateless JWT cannot be revoked
Once issued, a valid JWT works until it expires. There’s no server-side “log out”. To revoke early, either keep token IDs (
jti) in a denylist checked invalidate(), or shortenexpiresInand rely on refresh-token rotation. The whole appeal of JWT (stateless verification) bites back here.
Common errors
| Symptom | Likely cause |
|---|---|
Every authenticated request returns 401 Unauthorized | Secret mismatch between JwtModule.register({ secret }) and JwtStrategy({ secretOrKey }) |
Unknown authentication strategy "jwt" | JwtStrategy not listed in any module’s providers, or PassportModule not imported |
req.user is undefined inside the handler | The strategy’s validate() returned undefined, OR the route isn’t wrapped in JwtAuthGuard, OR the global guard sees @Public() |
401 on /auth/login after enabling the global guard | Missing @Public() on the login route |
TypeError: super(...) is not a constructor in JwtStrategy | Imported Strategy from passport instead of passport-jwt |
JsonWebTokenError: jwt malformed in logs | Client sent the header without the Bearer prefix, or sent a non-JWT token |
TokenExpiredError: jwt expired | Working as designed. Issue a new token via login or refresh-token flow |
Custom guard runs but super.canActivate() returns a Promise | AuthGuard.canActivate() can return boolean | Promise<boolean> | Observable<boolean>. Always return it; never await and discard |
See also
- Guards fundamental: the layer this recipe builds on. The
@Public()pattern lives there too. - DI-aware global providers: why
APP_GUARDoveruseGlobalGuards(). Same mechanism applies to pipes, interceptors, and filters. - Validation recipe: replace the
Record<string, any>body with aLoginDtovalidated byclass-validator. - Official docs: Authentication (no Passport), Passport recipe, @nestjs/jwt README, passport-jwt README.