JWT authentication with Passport
After 5+ years building production platforms, I've watched teams spend weeks debugging auth issues that could've been prevented with a solid foundation. JWT authentication with Passport in NestJS is the standard for good reason—but only when you implement it correctly. Today I'll walk you through the setup that actually scales, from strategy validation through token refresh logic, and show you the mistakes I see most often.
Why JWT and Passport Matter
When you're running a microservices architecture or a distributed system, session-based auth breaks down fast. You can't reliably share session state across services without adding a central store (Redis, database), and that becomes a bottleneck. JWT (JSON Web Token) doesn't have that problem—it's stateless by design. The token carries everything the server needs to verify it: your user ID, roles, tenant association, all cryptographically signed.
Passport is the de facto standard passport library for Node.js. It abstracts away the strategy pattern so you can swap authentication methods—JWT today, OAuth2 tomorrow—without rewriting your guards and controller logic. NestJS wraps Passport beautifully with decorators and dependency injection, so the integration feels native.
JwtStrategy — Validate the Token and Return the User Payload
The JwtStrategy is where token validation lives. When a request comes in with a Bearer token in the Authorization header, Passport extracts it, verifies the signature using your secret, and then calls your validate() method with the decoded payload.
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: string; // userId
roles: string[];
tenantId?: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private cfg: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: this.cfg.get('JWT_SECRET'),
algorithms: ['HS256'],
});
}
validate(payload: JwtPayload) {
return payload;
}
}Notice I'm keeping the payload minimal: sub (the user's ID), roles, and optionally tenantId. That's it. Don't dump the user's full profile into the token. Every byte counts because the token is sent with every request, and bloated tokens slow down your system.
The validate() method returns whatever you want to attach to request.user inside your controllers. In most cases, you're just returning the payload as-is. But this is where you could inject the database and load the full user if you needed custom logic—though I advise against it for performance reasons. More on that later.
I always specify algorithms: ['HS256'] explicitly. It's a security best practice; otherwise Passport will accept any algorithm the token claims to use, which opens you to algorithm-substitution attacks.
Global Guard and Public Opt-Out
Most routes need authentication. You want it applied everywhere by default, not sprinkled onto individual routes. NestJS lets you register a global guard in your app module.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}Now here's the guard itself:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
export const Public = () =>
Reflector.createDecorator();
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context) {
const isPublic = this.reflector.getAllAndOverride(Public, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}With this setup, every route is protected by default. To opt out, just add @Public() to the controller or handler:
@Post('login')
@Public()
async login(@Body() dto: LoginDto) {
// No token required
return this.authService.login(dto);
}This inversion—secure by default, open by exception—is safer than the alternative. You won't accidentally leave routes unprotected.
Short-Lived Access Tokens with Refresh Token Strategy
I see a lot of teams put a 24-hour or 7-day expiry on their access tokens. That's a red flag. If an access token is compromised, you've given an attacker a week of access.
The pattern that works in production is this: 15-minute access tokens paired with a separate refresh token that lives for days or weeks. The refresh token is stored securely (httpOnly cookie, or a database record) and only used to issue new access tokens. If an access token leaks, the damage window is 15 minutes.
Here's how you generate both:
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private cfg: ConfigService,
) {}
async login(userId: string, roles: string[]) {
const payload = { sub: userId, roles };
const accessToken = this.jwtService.sign(payload, {
secret: this.cfg.get('JWT_SECRET'),
expiresIn: '15m',
});
const refreshToken = this.jwtService.sign(payload, {
secret: this.cfg.get('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});
return { accessToken, refreshToken };
}
async refresh(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.cfg.get('JWT_REFRESH_SECRET'),
});
const newAccessToken = this.jwtService.sign(
{ sub: payload.sub, roles: payload.roles },
{
secret: this.cfg.get('JWT_SECRET'),
expiresIn: '15m',
},
);
return { accessToken: newAccessToken };
} catch (err) {
throw new UnauthorizedException('Invalid refresh token');
}
}
}Notice I'm using a separate JWT_REFRESH_SECRET. This isn't strictly necessary, but it's cleaner conceptually—refresh tokens and access tokens are different animals and shouldn't use the same key. If one secret leaks, you can rotate it without invalidating all tokens created with the other.
Keep Payloads Lean
Here's a subtle mistake I see repeatedly: developers put too much into the JWT payload, thinking it saves database queries.
What you actually want in the token:
sub: User IDroles: Array of role stringstenantId: If you're multi-tenant
What you should load on-demand from the database:
- Email, profile name, avatar
- Permissions (if you use fine-grained permission checks)
- Feature flags
- Any data that might change between token issue and expiry
Why? Because once a token is issued, it's immutable. If you encode the user's email and they change it, the token won't reflect that for 15 minutes. If you encode a role and it gets revoked, they're still acting under that role until the token expires. This is a security and consistency nightmare.
The 15-minute window makes this tractable. A brief cache at the application level (using something like Redis with a 5-minute TTL) can cover most cases, and you get fast lookups without the staleness problem.
Common Mistakes
Mistake 1: Long-lived access tokens
I see tokens with 7-day or 30-day expiries. This inflates your attack surface. Stick to 15 minutes. Seriously.
Mistake 2: Not validating token expiry on refresh
If your refresh endpoint doesn't verify that the refresh token hasn't been tampered with (e.g., by checking the signature), an attacker can craft their own refresh tokens. Always call verify(), never just decode.
Mistake 3: Putting the entire user object in the payload
It bloats the token, makes updates stale, and creates a false sense of security ("I have the user data already, no need to load it again"). It's a trap.
Mistake 4: Using a weak JWT secret
Your JWT_SECRET should be a strong random string (at least 32 characters). Store it in environment variables, never in code. And rotate it periodically if you can.
Mistake 5: Forgetting to handle token expiry gracefully on the client
When the 15-minute token expires, the client should catch the 401 response, call the refresh endpoint, retry the original request, and continue. If you don't implement this, the user experience is terrible.
Wiring It All Together
Here's what your auth module looks like when it's done right:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '15m' },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}Then in your controller, you're just calling the service:
@Post('login')
@Public()
async login(@Body() dto: LoginDto) {
return this.authService.login(dto.userId, dto.roles);
}
@Post('refresh')
@Public()
async refresh(@Body() dto: { refreshToken: string }) {
return this.authService.refresh(dto.refreshToken);
}Any other endpoint gets the global JwtAuthGuard automatically. Inside your handlers, request.user contains your JwtPayload.
Key Takeaways
- Use JwtStrategy to validate tokens and extract the payload into
request.user. - Register JwtAuthGuard globally; use
@Public()to opt out. Secure by default is safer. - Keep access tokens short-lived (15 minutes) and back them with a refresh token for longer sessions.
- Store only
sub,roles, andtenantIdin the token. Load everything else on-demand. - Always
verify()tokens, never just decode. Check the signature. - Use a separate secret for refresh tokens if your architecture allows it.
- Implement client-side token refresh so users don't get logged out every 15 minutes.






Comments
Loading comments…