Skip to content

Authentication with Passport and JWT

Aman Kumar Singh7 min read
Part 12 of 40From the NestJS Production Guide series
Authentication with Passport and JWT — article by Aman Kumar Singh

The last article in this series covered Configuration and Environment: getting secrets, connection strings, and per-environment values into the app safely before you write a single business feature. Authentication is usually the first feature that actually needs those secrets. A JWT signing key is exactly the kind of value that has no business living in source control.

This is part of the NestJS Production Guide. This article is where the series moves from structure to a real, security-sensitive feature. Everything from the request lifecycle article applies directly here: authentication belongs in a guard, not in a controller method, and the reasoning for that placement gets more concrete once you're the one wiring it up.

I'm going to walk through building username/password login with Passport's local strategy, issuing a JWT, and validating that JWT on protected routes with Passport's JWT strategy. That's the standard, boring, well-supported path for a NestJS API, and boring is what you want for the part of your system that decides who gets in.

Why Passport, and why JWT

NestJS ships @nestjs/passport as a thin wrapper around the Passport.js ecosystem rather than inventing its own auth abstraction. That's a deliberate choice worth respecting: Passport has hundreds of strategies (local, JWT, OAuth providers, SAML) that are battle-tested and maintained outside the NestJS core team. Writing your own strategy abstraction would mean reinventing something the ecosystem already solved. It would also forfeit access to all those existing strategies when you eventually need Google or GitHub login.

JWT is the natural fit for the token itself once you're past a single-server toy app. A session ID requires a shared store (typically Redis) that every instance of your API can read, which is fine and often correct, but JWT lets you skip that lookup entirely for authentication: the token is self-contained and verifiable with just the signing secret, no database round trip required. For a horizontally scaled API behind a load balancer, that's a real operational simplification.

The tradeoff is revocation. A session in Redis can be deleted the instant you want to log someone out. A JWT is valid until it expires, full stop, unless you build a denylist alongside it, which reintroduces the shared-store lookup you were trying to avoid. This is why short-lived access tokens paired with a longer-lived, revocable refresh token are the standard pattern. Treat "JWT means no database access needed" as true for the access token's happy path, and nothing broader than that.

Building the local strategy for login

The local strategy handles the login endpoint: taking a username and password, verifying them, and handing back a user object if they check out. Passport strategies in NestJS are just injectable providers that extend PassportStrategy.

// src/modules/auth/strategies/local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({ usernameField: 'email' });
  }

  async validate(email: string, password: string) {
    const user = await this.authService.validateUser(email, password);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }
    return user;
  }
}

The validate method is Passport's contract, not Nest's; whatever it returns gets attached to request.user. The actual password check lives in a service method rather than the strategy itself: the strategy's job is to adapt Passport's flow to your app, and business logic belongs elsewhere.

// src/modules/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}

  async validateUser(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);
    if (!user) {
      return null;
    }
    const passwordMatches = await bcrypt.compare(password, user.passwordHash);
    if (!passwordMatches) {
      return null;
    }
    const { passwordHash, ...safeUser } = user;
    return safeUser;
  }
}

Two details here matter more than they look. First, bcrypt.compare runs at the same cost whether the email exists or not, because both branches return null in roughly the same shape; you don't want a timing difference or an error message telling an attacker whether an email is registered. Second, the password hash never leaves this method. Stripping it before returning the user object means it's structurally impossible for a later piece of code, a serializer, a logger, a response DTO, to accidentally leak it.

The login endpoint itself wires the strategy in with a guard, then issues the token:

// src/modules/auth/auth.controller.ts
import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

AuthGuard('local') runs the local strategy's validate method before the handler executes; if it throws, the handler never runs. By the time login executes, req.user is already verified. This is the guard-first pattern from the architecture overview article, applied to the specific case of credential checking.

Issuing and validating JWTs

Once you have a validated user, issuing a token is a matter of signing a payload with @nestjs/jwt:

// src/modules/auth/auth.service.ts (continued)
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async login(user: { id: string; email: string; roles: string[] }) {
    const payload = { sub: user.id, email: user.email, roles: user.roles };
    return {
      accessToken: this.jwtService.sign(payload, { expiresIn: '15m' }),
    };
  }
}

Keep the payload small and non-sensitive. It's base64-encoded, not encrypted, so anyone holding the token can read its contents without the signing secret; they just can't forge a new one without it. sub, email, and a coarse role list are reasonable. Full permission sets, PII beyond email, or anything that changes frequently doesn't belong here, because updating it means the user has to get a new token.

Validating the token on protected routes is a second Passport strategy, this one extracting and verifying the JWT from the Authorization header:

// src/modules/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
    });
  }

  async validate(payload: { sub: string; email: string; roles: string[] }) {
    return { userId: payload.sub, email: payload.email, roles: payload.roles };
  }
}

Pulling JWT_SECRET through ConfigService.getOrThrow rather than process.env directly is the payoff from the configuration article: it fails fast at startup instead of silently signing tokens with undefined if the env var is missing. ignoreExpiration: false is worth calling out explicitly even though it's the default, because a passing test suite that forgets to set it can silently accept expired tokens forever.

Protecting a route is then a one-line guard, just like the local strategy:

// src/modules/users/users.controller.ts
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller('users')
export class UsersController {
  @UseGuards(AuthGuard('jwt'))
  @Get('me')
  getProfile(@Request() req) {
    return req.user;
  }
}

Production pitfalls worth knowing before they bite

Storing the JWT secret in code or committing it in a .env file to the repo is the mistake everyone knows not to make and someone on every team still makes once. Treat JWT_SECRET like any other production credential: generated with real entropy, stored in your secrets manager, rotated on a schedule, never in source control.

A more subtle pitfall is token expiration policy. A long-lived access token (say, seven days) means a compromised token stays valid for a week with no way to revoke it short of rotating the signing secret, which invalidates every other user's session too. Short-lived access tokens (minutes, not days) paired with a refresh token that's stored server-side and can be individually revoked gives you a real kill switch. That refresh flow is enough of a topic on its own that it deserves separate treatment rather than a rushed paragraph here.

Storage location for the token on the client matters just as much as the token's lifetime. localStorage is readable by any script on the page, which makes it vulnerable to XSS in a way that an httpOnly cookie isn't. If you're building the frontend in Next.js alongside this API, an httpOnly, Secure, SameSite cookie is the safer default for the access token, at the cost of needing CSRF protection for state-changing requests, since cookies get sent automatically by the browser.

Finally, don't skip rate limiting on the login endpoint itself. Everything above validates credentials correctly, but a login route with no throttling is an open invitation to credential stuffing regardless of how solid your password hashing is.

Key takeaways

  • NestJS wraps Passport rather than replacing it, which gives you access to the entire Passport strategy ecosystem, local, JWT, OAuth, and more, without reinventing any of it.
  • JWTs skip the shared-store lookup that session IDs require, which matters for horizontally scaled APIs, but they trade that away for harder revocation.
  • Keep the JWT payload small and non-sensitive; it's readable by anyone holding the token, and updating it means issuing a new one.
  • Pull secrets like `JWT_SECRET` through `ConfigService.getOrThrow` so a missing value fails at startup instead of silently signing invalid tokens.
  • Pair short-lived access tokens with a revocable refresh token rather than issuing long-lived access tokens with no kill switch.
  • Rate limit the login endpoint independently of how strong your password hashing is; hashing doesn't stop credential stuffing on its own.

Frequently asked questions

Do I need Passport if I'm only ever going to use JWT authentication?

You can validate a JWT with a plain guard and no Passport strategy at all, and some teams do exactly that for a single, simple use case. Passport earns its keep once you need more than one auth method, local login plus Google OAuth plus JWT for the API, since all three then share the same guard and strategy pattern instead of three different bespoke implementations.

Why does the JWT strategy's validate method not check the database?

Because that would defeat the point of using a JWT in the first place: verifying the signature is enough to trust the payload, and adding a database lookup on every request reintroduces the same per-request cost that session-based auth has. If you need to check something that changes at runtime, like whether a user was disabled after their token was issued, that's a signal you need a short expiration and a denylist, not a lookup on every request.

Should the JWT payload include the user's full permission list?

Generally no, for anything beyond a small, coarse role list. Permissions tend to change more often than roles, and every change means the old token is now stale until it expires or the user logs in again. A common pattern is to keep roles in the token and resolve fine-grained permissions from the database or a cache at request time, which is exactly what the next article in this series covers.

How long should a JWT access token actually live?

There's no universal number, but shorter is generally safer at the cost of more refresh traffic. Minutes to low hours is a common range for access tokens, paired with a refresh token measured in days that's stored server-side so it can be revoked. Start conservative and loosen it only if you have a concrete reason.

Is it safe to store a JWT in localStorage for a React or Next.js frontend?

It works, but it's more exposed to XSS than an httpOnly cookie, since any script running on the page can read localStorage. An httpOnly, Secure cookie keeps the token out of reach of JavaScript entirely, at the cost of needing CSRF protection on state-changing routes because the browser attaches cookies automatically.

What's the actual difference between a guard and a Passport strategy in NestJS?

`AuthGuard('local')` or `AuthGuard('jwt')` is the NestJS guard that plugs into the request pipeline described in the architecture overview article; the string tells it which Passport strategy to run. The strategy itself is where the actual verification logic, checking a password or validating a signature, lives. The guard is the wiring; the strategy is the implementation.

Related articles

JWT Authentication in NestJS — Aman Kumar Singh
Guards and Authorization — Aman Kumar Singh
Multi-Factor Authentication (MFA) — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.