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.
We also look at a simpler option at the end, for anyone starting a new backend who'd rather not assemble auth from parts.
NestJS doesn't 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's 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's 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
}
Don't store refresh tokens in localStorage, they're 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 don't 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.NestJS auth is powerful but wordy. You're writing seven files of infrastructure before your actual business logic runs. For a lot of new backends, that's more machinery than you want.
Encore takes a different approach: authentication is a first-class concept in the framework, not something you assemble from Passport strategies.
// auth/auth.ts
import { Gateway, authHandler } from "encore.dev/auth";
import { Header } from "encore.dev/api";
import { APIError } from "encore.dev/api";
import jwt from "jsonwebtoken";
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, process.env.JWT_SECRET!) as AuthData;
return { userID: payload.userID, email: payload.email };
} catch {
throw APIError.unauthenticated("invalid token");
}
});
export const gateway = new Gateway({ authHandler: auth });
That's the whole auth layer. 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 Encore's examples for Clerk, Auth0, and WorkOS.
Encore handles the infrastructure around auth too, Postgres for user storage, secrets for the JWT key, Pub/Sub for async user events, all declared in TypeScript and provisioned on AWS or GCP automatically.
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.
If you're 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:
brew install encoredev/tap/encore
encore app create my-app --example=ts/empty
cd my-app && encore run
The local dashboard at localhost:9400 shows every API, every request, and the auth context attached to each one.
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.