Updated Nov 5, 20269 min read

NestJS Authentication: A Complete Guide

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

Illustration for NestJS Authentication: A Complete Guide

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.

How Does NestJS Authentication Work?

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:

  • Guards: the NestJS abstraction for "is this request allowed through?" Guards run before the route handler.
  • Strategies: Passport's concept. A strategy is a class that knows how to verify a credential (username/password, JWT, OAuth token, etc.) and produce a user object.
  • Decorators: @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.

How Do You Set Up JWT Authentication in NestJS?

JWT is the most common choice for stateless APIs. Here is the full setup.

Install dependencies

npm install @nestjs/passport @nestjs/jwt passport passport-jwt passport-local bcrypt npm install -D @types/passport-jwt @types/passport-local @types/bcrypt

The users service

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; } }

The auth service

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), }; } }

The Local strategy (for the login endpoint)

// 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; } }

The JWT strategy (for protected routes)

// 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.

The auth module

// 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 {}

The auth controller

// 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); } }

Protecting a route

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.

How Do You Add Role-Based Authorization?

Authentication tells you who the user is. Authorization decides what they can do. NestJS gives you custom guards for this.

Defining a role guard

// 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)); } }

A roles decorator

// auth/roles.decorator.ts import { SetMetadata } from "@nestjs/common"; export const ROLES_KEY = "roles"; export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

Usage

@UseGuards(AuthGuard("jwt"), RolesGuard) @Roles("admin") @Delete(":id") remove(@Param("id") id: string) { // only runs for admins }

How Do You Handle Refresh Tokens?

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.

How Do You Add OAuth (Google, GitHub)?

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.

What Are the Common NestJS Auth Pitfalls?

Things that trip up NestJS auth setups:

  1. Storing JWT secrets in committed config: use environment variables and a secrets manager (Encore has a secrets primitive for this). JwtModule.register({ secret: "hardcoded" }) is the classic one.
  2. Long-lived JWTs with no revocation: if your access token is valid for 30 days, a stolen token is valid for 30 days. Keep access tokens short, use refresh tokens.
  3. Missing @UseGuards(AuthGuard('jwt')) on individual routes: NestJS guards do not cascade unless you apply them at the controller or globally.
  4. Using 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.
  5. Not hashing refresh tokens in the database: a DB leak with plaintext refresh tokens is a full account takeover.
  6. Trusting req.user without a guard: without the guard running, req.user is undefined or whatever the last unrelated request left.

Is There a Simpler Way to Add Authentication?

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.

How Do You Get Started?

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:

Install Encore
brew install encoredev/tap/encore &&
encore app create
Copy

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders