Passport strategies and JWT auth
Passport gives Nest two strategies — LocalStrategy validates credentials on /login, JwtStrategy verifies a Bearer token on every protected route. A global JwtAuthGuard fails closed; @Public() opts routes out. A JWT is signed, not encrypted.
The pen-test report lands with one finding flagged critical: a former contractor’s token still works, three weeks after their access was revoked. You check — the row is gone from the users table, the SSO seat is reclaimed, and yet GET /admin/billing returns 200 for a token they minted in March. Then you read your own JwtModule.register: signOptions: { expiresIn: '90d' }. There is no logout, no revocation, no denylist — a JWT, once signed, is valid until it expires, and you told it to live for ninety days. The fix is not a patch; it is understanding what a signed token actually is and how Passport hands one out and checks it back.
Two strategies, two moments
Authentication in Nest is two distinct moments, and Passport models each with a strategy. The first moment is login: the client sends a username and password exactly once, you check them against the database, and if they match you hand back a token. That is LocalStrategy. The second moment is every request after: the client presents the token, you verify its signature and expiry, and you trust the identity inside it without touching the database. That is JwtStrategy. A strategy is a class extending PassportStrategy(...) whose validate() method returns the principal — and whatever validate() returns is what Passport attaches to req.user.
LocalStrategy reads the credentials, compares the password against the stored hash, and returns the user (minus the hash). The matching LocalAuthGuard is a one-liner — AuthGuard('local') — and you put it on POST /login so the strategy runs before your handler:
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private users: UsersService) {
super(); // defaults to fields named 'username' and 'password'
}
async validate(username: string, password: string) {
const user = await this.users.findByName(username);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException();
}
const { passwordHash, ...safe } = user;
return safe; // -> becomes req.user
}
}The /login handler then mints the token. By the time the handler runs, the guard has already populated req.user, so all the handler does is sign a payload — never put the password or anything secret in it:
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req) {
const payload = { sub: req.user.id, username: req.user.username };
return { access_token: this.jwt.sign(payload) };
}Wiring the module: secret from config, not source
JwtModule needs a signing secret and a token lifetime, and both belong in configuration, not a string literal in your repo. registerAsync lets you inject ConfigService through a useFactory, so the secret comes from the environment and rotates without a code change. PassportModule registers the strategy infrastructure; you provide your strategy classes as normal providers:
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '15m' }, // short-lived access token
}),
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}JwtStrategy is the verifier. ExtractJwt.fromAuthHeaderAsBearerToken() tells Passport to pull the token from Authorization: Bearer <token>; secretOrKey must be the same secret used to sign, or every verification fails; and leaving ignoreExpiration at its default false is what enforces the TTL. Passport verifies the signature and expiry before your validate() runs, so by the time you see the payload it is already proven authentic — you just shape the principal:
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // reject expired tokens (the default — keep it)
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: { sub: string; username: string }) {
return { userId: payload.sub, username: payload.username }; // -> req.user
}
}Fail closed: a global guard plus @Public()
The dangerous default is opt-in protection — sprinkling @UseGuards(JwtAuthGuard) on each route and hoping nobody forgets one. Invert it: register the JWT guard globally with APP_GUARD so every route is protected by default, then carve explicit holes with a @Public() decorator the guard checks via the Reflector. Forgetting the decorator now leaves a route locked, not open — the safe failure mode.
// public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);// 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>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true; // @Public() route -> skip JWT verification
return super.canActivate(context); // else run AuthGuard('jwt')
}
}// app.module.ts — bind the guard globally
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],Now POST /login and GET /health carry @Public(); everything else demands a valid Bearer token. The flow end to end:
What req.user holds after each guard
The two guards populate req.user from different sources, and conflating them is a common bug. After LocalAuthGuard, req.user is whatever LocalStrategy.validate() returned — the freshly-loaded database user. After JwtAuthGuard, req.user is whatever JwtStrategy.validate(payload) returned — derived from the token’s claims, not a database read. If a user’s roles change after the token is issued, a JWT-authenticated request still sees the old claims until the token expires.
| Aspect | LocalStrategy | JwtStrategy |
|---|---|---|
| Runs on | POST /login only | Every protected route |
| validate() input | username, password | decoded, verified payload |
| Hits the DB? | Yes — load user, compare hash | No — trusts token claims |
| req.user becomes | the loaded user (no hash) | whatever validate(payload) returns |
| Guard | AuthGuard(‘local’) | AuthGuard(‘jwt’), often global |
▸Why this works
Why is a JWT signed but not encrypted? The token is three base64url parts — header, payload, signature — joined by dots. Base64url is encoding, not encryption: anyone holding the token can decode the payload and read every claim in plaintext (paste one into jwt.io and watch). The signature does not hide the payload; it only proves the payload was not tampered with and was minted by a holder of the secret. So a JWT guarantees integrity and authenticity, never confidentiality. Never put a password, a secret, PII, or anything you would not print on a postcard into the claims — put an opaque user id and let the server look up the rest.
Constraint: a SaaS dashboard where an admin must be able to KILL a compromised session within seconds, traffic is high, and you run several stateless API instances behind a load balancer. Which auth model fits?
After a request passes the global JwtAuthGuard, where does the value in req.user come from?
You register JwtAuthGuard globally via APP_GUARD. What must POST /login carry, and why?
- 01Walk the full Passport + JWT flow from login to a protected request, naming each strategy, what its validate() does, and what ends up in req.user.
- 02Why is a JWT not a session you can log out of, and how do access + refresh tokens solve it?
Authentication in Nest is two moments, each modeled by a Passport strategy whose validate() return value becomes req.user. LocalStrategy runs once behind LocalAuthGuard on POST /login: it loads the user, compares the password against the bcrypt hash, and returns the user; the handler then signs a JWT with jwtService.sign(payload), putting only an opaque id and non-secret claims in it. The AuthModule imports PassportModule and JwtModule.registerAsync, pulling the secret and signOptions.expiresIn from ConfigService so the signing key lives in the environment. JwtStrategy verifies every protected request: ExtractJwt.fromAuthHeaderAsBearerToken() pulls the Bearer token, secretOrKey must match the signing secret, ignoreExpiration stays false to enforce the TTL, and validate(payload) shapes req.user from the verified claims with no database read. Bind JwtAuthGuard globally via APP_GUARD so every route is protected by default — fail closed — and opt routes out with a @Public() decorator the guard reads through the Reflector. The senior caution: a JWT is signed, not encrypted, so anyone can base64-decode the payload — never put secrets or PII in claims — and a signed token can’t be un-issued, so revocation comes from short-lived access tokens plus a rotating, server-tracked refresh token rather than a long-lived JWT that lives for months with no kill switch. Now when you see a pen-test finding that says “token still valid after access revoked,” you know exactly where to look: expiresIn too long, no refresh rotation, no @Public() inversion — and you know the fix before the post-mortem starts.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.