JWT Authentication in NestJS
- nestjs
- jwt
- authentication
- security
- nodejs
- typescript
- backend
- passport
Last time in this series, I covered A Git Workflow for Small Teams, because a codebase without a sane branching model falls apart before the product does. Now that the repo is set up and the team can collaborate on it without stepping on each other, it's time to add the first piece of application logic that actually touches every other feature: authentication.
This is part of the Full Stack SaaS Masterclass, and it opens Module 2, the authentication module. JWT is where most NestJS backends start, and it's a reasonable place to start, provided you understand what a JWT actually buys you and what it doesn't.
I want to be direct about scope here. This article covers issuing and verifying access tokens with Passport and @nestjs/jwt, structuring the auth module, and the pitfalls that bite teams in production. Refresh tokens, rotation, and revocation get their own article next, because cramming both into one piece tends to gloss over the parts that actually cause incidents.
Why JWT, and what it actually solves
A JSON Web Token is a signed, self-contained claim about a user. The server issues it once. From then on, any service holding the signing secret (or public key, for asymmetric signing) can verify the token without a database lookup or a call to a session store. That's the entire value proposition: stateless verification.
That property matters most when you have more than one backend process. A single-instance monolith with sticky sessions barely benefits from JWTs; a session cookie backed by Redis would do the job with less complexity. But the moment you have multiple API instances behind a load balancer, or a separate service that needs to verify who's calling it without hitting your main database, stateless tokens remove a network hop and a shared-state dependency.
The tradeoff is real, though: once a JWT is issued, you can't take it back before it expires. There's no built-in server-side "log this user out now" unless you build one. Keep that in mind, because it shapes every design decision below, including why you'll want short-lived access tokens paired with a separate refresh mechanism, which is the subject of the next article.
Setting up the auth module
NestJS's @nestjs/passport and @nestjs/jwt packages give you the building blocks. The shape that's held up well across projects I've built is: an AuthModule that owns login and token issuance, a JwtStrategy that Passport uses to validate incoming tokens, and a JwtAuthGuard to protect routes.
npm install @nestjs/passport @nestjs/jwt passport passport-jwt
npm install -D @types/passport-jwt
Start with the module wiring. The secret and expiry come from configuration, never hardcoded:
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_ACCESS_EXPIRY', '15m'),
},
}),
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
The AuthService handles credential validation and token signing. Password comparison uses bcrypt, never a plain string equality check, and the payload embedded in the token is deliberately minimal:
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
export interface JwtPayload {
sub: string;
email: string;
orgId: string;
role: 'owner' | 'admin' | 'member';
}
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async validateCredentials(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
async issueAccessToken(user: {
id: string;
email: string;
orgId: string;
role: JwtPayload['role'];
}) {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
orgId: user.orgId,
role: user.role,
};
return this.jwtService.sign(payload);
}
}
Notice what's missing from that payload: no permissions arrays, no profile data, nothing that changes often. Every field you add to a JWT payload is a field that's stale the instant the underlying record changes, until the token expires. orgId and role are there because they're needed on nearly every request for tenant scoping and authorization. They change rarely enough that a short-lived token's staleness window stays acceptable.
The strategy is where verification happens on incoming requests:
// src/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { JwtPayload } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: JwtPayload) {
return {
userId: payload.sub,
email: payload.email,
orgId: payload.orgId,
role: payload.role,
};
}
}
Whatever validate returns becomes request.user, available to every guard and controller downstream. Guarding a route is then a one-liner:
// src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// src/organizations/organizations.controller.ts
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('organizations')
export class OrganizationsController {
@UseGuards(JwtAuthGuard)
@Get('me')
getCurrentOrg(@Req() req: any) {
return { orgId: req.user.orgId };
}
}
Tradeoffs worth naming out loud
JWT is not the only way to do this, and it's not always the right call. If your entire product is a single Next.js app talking to a single NestJS instance, an HTTP-only session cookie backed by Redis gives you instant revocation and a simpler mental model, at the cost of a Redis round trip per request. That round trip is usually cheap, and "usually cheap" beats "can't log anyone out" for a lot of applications, especially in the early stage before you have multiple services that need to independently verify identity.
Where JWTs earn their complexity is multi-service architectures: a NestJS API, a separate notification worker, maybe a public API surface, all needing to verify the same identity without all hitting the same session store. That's the scenario stateless tokens were built for.
A second tradeoff: symmetric (HS256) versus asymmetric (RS256) signing. HS256 is simpler, one shared secret signs and verifies. But that secret has to live on every service that verifies tokens, which means every one of those services could also forge a token if compromised. RS256 signs with a private key held only by the auth service and verifies with a public key that can be distributed freely. For a single-service monolith, HS256 is fine. The moment other services need to verify tokens without being trusted to issue them, move to RS256.
Production pitfalls
Long-lived access tokens. The single most common mistake I see is issuing an access token with a multi-day or multi-week expiry "for convenience." That defeats the entire safety model, because a leaked token is now valid for as long as the expiry says, with no way to revoke it early. Keep access tokens short, 15 minutes is a reasonable default, and handle the resulting friction with a refresh token, which is exactly what the next article covers.
Storing tokens in localStorage. It's tempting because it's easy to read from JavaScript, but that also means any XSS vulnerability anywhere in your frontend can exfiltrate every logged-in user's token. An HTTP-only, secure cookie is the safer default for browser clients, even though it complicates the request flow slightly (you need CSRF protection alongside it, and cross-origin requests need SameSite configured deliberately).
Trusting the payload for authorization on stale data. If a user's role changes mid-session, their existing access token still carries the old role until it expires. For most apps that's an acceptable window given a short expiry. For anything sensitive, either keep expiry very short or check a lightweight "is this user still active/role X" flag against a fast store like Redis on the critical paths.
No iss/aud validation. If you ever have more than one service issuing tokens, or you accept tokens from a third party (SSO, for example), validate the issuer and audience claims explicitly. Skipping this is how a token meant for one context ends up accepted somewhere it shouldn't be.
Committing secrets. JWT_ACCESS_SECRET belongs in your environment configuration, never in source control, and it should differ between environments. Rotating it invalidates every outstanding token, which is a blunt but sometimes necessary revocation tool in an incident.
Key takeaways
- JWTs buy you stateless verification across multiple services; they cost you the ability to revoke a token before it expires.
- Keep the payload minimal: identity and the few claims (org, role) that every request needs, not a full user profile.
- Prefer short access token expiries (minutes, not days) and pair them with a proper refresh flow rather than stretching expiry for convenience.
- HS256 is fine for a single trusted service; move to RS256 once other services need to verify tokens without being able to issue them.
- Store tokens in HTTP-only cookies for browser clients rather than localStorage to limit XSS blast radius.
- Validate the full token, expiry, issuer, and audience, not just the signature.
Frequently asked questions
Should I use JWT or sessions for a new NestJS SaaS?
If you're running a single backend instance early on, session cookies backed by Redis are simpler and give you instant revocation. Reach for JWTs once you have multiple services or instances that need to independently verify a caller's identity without a shared session store.
How long should a JWT access token last?
Short. Fifteen minutes is a common, reasonable default. The access token's job is to be safely disposable; long-lived convenience belongs in a separate refresh token with its own storage and rotation strategy, covered in the next article.
Can I put user permissions inside the JWT payload?
You can, but every claim you embed is only as fresh as the token's age. Coarse, rarely-changing claims like role or organization ID are usually fine. Fine-grained, frequently-changing permissions are better checked against a live source on sensitive operations.
Where should the frontend store the JWT, localStorage or cookies?
HTTP-only, secure cookies for browser clients. localStorage is readable by any script running on the page, so an XSS bug anywhere in your frontend becomes a full account-takeover vector. Cookies require CSRF handling in exchange, which is a fair trade for the reduced attack surface.
What's the difference between HS256 and RS256 for signing JWTs?
HS256 uses one shared secret for both signing and verifying, simple but risky if multiple services need to verify tokens, since any of them holding the secret could also forge one. RS256 signs with a private key held only by the issuing service and verifies with a public key that's safe to distribute, which fits multi-service setups better.
How do I log a user out immediately if JWTs can't be revoked?
You generally can't revoke a bare JWT before it expires, which is exactly why access tokens should be short-lived. For an immediate, forced logout, you maintain a denylist or a refresh-token revocation list in a fast store like Redis and check it on the refresh path, not on every access-token request.
Further reading
Explore more on

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.