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)

LayerPackageWhat it does
OrchestratorpassportMaintains the registry of named strategies and runs the verify callback
Strategy implementationpassport-jwtKnows how to extract a JWT from a request and verify its signature
Nest wrapper@nestjs/passportAdapts Passport to Nest: PassportStrategy base class, AuthGuard(name)
Your codeJwtStrategy + JwtAuthGuardSubclass 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-jwt

Login 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:

OptionPurpose
jwtFromRequestWhere to read the token. fromAuthHeaderAsBearerToken() reads Authorization: Bearer …
ignoreExpirationfalse (default) lets Passport reject expired tokens with 401 automatically
secretOrKeySame 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).

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

Response (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: BasicAuth middleware is fine, no Passport needed.

Gotchas

Common errors

SymptomLikely cause
Every authenticated request returns 401 UnauthorizedSecret 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 handlerThe 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 guardMissing @Public() on the login route
TypeError: super(...) is not a constructor in JwtStrategyImported Strategy from passport instead of passport-jwt
JsonWebTokenError: jwt malformed in logsClient sent the header without the Bearer prefix, or sent a non-JWT token
TokenExpiredError: jwt expiredWorking as designed. Issue a new token via login or refresh-token flow
Custom guard runs but super.canActivate() returns a PromiseAuthGuard.canActivate() can return boolean | Promise<boolean> | Observable<boolean>. Always return it; never await and discard

See also