JWT, Passport strategies, guards, and how to wire it all together

Authentication is one of the things NestJS handles fairly well out of the box, at the cost of learning a stack of concepts (Passport strategies, guards, decorators, module configuration) before you can log a user in. This guide walks through what NestJS gives you, how to wire up JWT and Passport correctly, and where teams typically get stuck.
To add authentication in NestJS, install @nestjs/passport and @nestjs/jwt, write a Local strategy for login and a JWT strategy for protected routes, and apply AuthGuard('jwt') to the routes that need a user. That is about 150 lines across seven files. With Encore, a tool that works alongside NestJS, auth is one authHandler plus auth: true on each endpoint, the signing key is a secret set with encore secret set, and an existing NestJS app can run inside Encore while you migrate.
NestJS does not have built-in authentication. It has integrations with Passport, the de facto Node.js auth library, through the @nestjs/passport package. Around that, it gives you:
@UseGuards(AuthGuard('jwt')) to apply a guard, @Req() to grab the authenticated user off the request.The flow: request comes in → guard triggers the strategy → strategy validates → user is attached to req.user → your handler runs.
JWT is the most common choice for stateless APIs. Here is the full setup.
npm install @nestjs/passport @nestjs/jwt passport passport-jwt passport-local bcrypt npm install -D @types/passport-jwt @types/passport-local @types/bcrypt
Your users service owns password hashing and lookup. Never store plaintext passwords.
// users/users.service.ts
import { Injectable } from "@nestjs/common";
import * as bcrypt from "bcrypt";
@Injectable()
export class UsersService {
constructor(private readonly db: DatabaseService) {}
async findByEmail(email: string) {
return this.db.users.findOne({ where: { email } });
}
async create(email: string, password: string) {
const hash = await bcrypt.hash(password, 10);
return this.db.users.insert({ email, password: hash });
}
async validateCredentials(email: string, password: string) {
const user = await this.findByEmail(email);
if (!user) return null;
const ok = await bcrypt.compare(password, user.password);
return ok ? user : null;
}
}
Issues tokens after successful credential verification.
// 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 validateUser(email: string, password: string) {
const user = await this.usersService.validateCredentials(email, password);
if (!user) throw new UnauthorizedException();
const { password: _, ...result } = user;
return result;
}
async login(user: { id: number; email: string }) {
const payload = { sub: user.id, email: user.email };
return {
access_token: await this.jwtService.signAsync(payload),
};
}
}
// auth/local.strategy.ts
import { Strategy } from "passport-local";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable, UnauthorizedException } from "@nestjs/common";
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();
return user;
}
}
// auth/jwt.strategy.ts
import { ExtractJwt, Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: { sub: number; email: string }) {
return { userId: payload.sub, email: payload.email };
}
}
Whatever validate returns becomes req.user on downstream requests.
// auth/auth.module.ts
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { LocalStrategy } from "./local.strategy";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "1h" },
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}
// auth/auth.controller.ts
import {
Controller,
Post,
UseGuards,
Request,
Body,
} 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);
}
}
Apply the JWT guard anywhere you want authenticated access:
// users/users.controller.ts
@UseGuards(AuthGuard("jwt"))
@Get("me")
getProfile(@Request() req) {
return req.user;
}
That is a basic working NestJS auth stack. About 150 lines of code across seven files.
Authentication tells you who the user is. Authorization decides what they can do. NestJS gives you custom guards for this.
// auth/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./roles.decorator";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
// auth/roles.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@UseGuards(AuthGuard("jwt"), RolesGuard)
@Roles("admin")
@Delete(":id")
remove(@Param("id") id: string) {
// only runs for admins
}
JWTs should be short-lived (15 min to 1 hour). For longer sessions, issue a refresh token separately, store it server-side, and rotate it on each use.
// auth/auth.service.ts (additions)
async login(user: { id: number; email: string }) {
const payload = { sub: user.id, email: user.email };
const accessToken = await this.jwtService.signAsync(payload, {
expiresIn: "15m",
});
const refreshToken = crypto.randomBytes(32).toString("hex");
await this.db.refreshTokens.insert({
userId: user.id,
token: await bcrypt.hash(refreshToken, 10),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
return { access_token: accessToken, refresh_token: refreshToken };
}
async refresh(refreshToken: string) {
// look up all non-expired refresh tokens for the presenting user,
// verify by bcrypt.compare, rotate the token, issue a new access token
}
Do not store refresh tokens in localStorage, they are as sensitive as passwords. HTTP-only cookies or secure native storage only.
Passport has strategies for every major provider. passport-google-oauth20 for Google:
import { Strategy, VerifyCallback } from "passport-google-oauth20";
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
constructor() {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback",
scope: ["email", "profile"],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
) {
const user = { email: profile.emails[0].value, name: profile.displayName };
done(null, user);
}
}
Same pattern applies to GitHub, Facebook, Apple, Microsoft, swap the strategy package.
Things that trip up NestJS auth setups:
JwtModule.register({ secret: "hardcoded" }) is the classic one.@UseGuards(AuthGuard('jwt')) on individual routes: NestJS guards do not cascade unless you apply them at the controller or globally.express-session with JWTs: pick one model. Sessions are stateful and easier to revoke; JWTs are stateless and easier to scale. Mixing both means you pay for both and benefit from neither.req.user without a guard: without the guard running, req.user is undefined or whatever the last unrelated request left.Yes. Encore replaces the guards, strategies, and module wiring with one auth handler. NestJS auth is powerful but wordy: you write seven files of plumbing before your business logic runs.
In Encore, authentication is built in rather than assembled from Passport strategies.
// auth/auth.ts
import { authHandler } from "encore.dev/auth";
import { APIError, Gateway, Header } from "encore.dev/api";
import { secret } from "encore.dev/config";
import jwt from "jsonwebtoken";
// Set a separate value for production and for other environments:
// encore secret set --type prod JWTSecret
// encore secret set --type dev,preview,local JWTSecret
const jwtSecret = secret("JWTSecret");
interface AuthParams {
authorization: Header<"Authorization">;
}
interface AuthData {
userID: string;
email: string;
}
export const auth = authHandler<AuthParams, AuthData>(async (params) => {
const token = params.authorization?.replace("Bearer ", "");
if (!token) throw APIError.unauthenticated("missing token");
try {
const payload = jwt.verify(token, jwtSecret()) as AuthData;
return { userID: payload.userID, email: payload.email };
} catch {
throw APIError.unauthenticated("invalid token");
}
});
export const gateway = new Gateway({ authHandler: auth });
That is the whole auth layer. The authHandler runs before every authenticated request, and the gateway routes through it. To require auth on an endpoint, you set auth: true:
import { api } from "encore.dev/api";
import { getAuthData } from "~encore/auth";
export const me = api(
{ method: "GET", path: "/me", auth: true, expose: true },
async () => {
const { userID, email } = getAuthData()!;
return { userID, email };
},
);
There are no guards, no Passport strategies, and no DI wiring, and the whole flow is type-safe.
For OAuth, you can integrate any provider's SDK inside the auth handler, or use a drop-in library like Better Auth.
Encore handles the infrastructure around auth too: Postgres for user storage, secrets for the JWT key, and a cron job to clear expired refresh tokens, all declared in TypeScript and provisioned in your own AWS or GCP account, or self-hosted with Docker.
If you are sticking with NestJS:
npm install @nestjs/passport @nestjs/jwt passport passport-jwt passport-local bcrypt
Then wire up the files above.
If you want to try the Encore approach (full quick start), install it with brew install encoredev/tap/encore && encore app create:
Windows: iwr https://encore.dev/install.ps1 | iex
Linux: curl -L https://encore.dev/install.sh | bash
Then cd into the app and run encore run.
The local dashboard at localhost:9400 shows every API, every request, and the auth context attached to each one, with distributed tracing built in.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.