04/20/26

NestJS Authentication: A Complete Guide

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

8 Min Read

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.

The NestJS Auth Model

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:

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

Setting Up JWT Authentication

JWT is the most common choice for stateless APIs. Here's 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's a basic working NestJS auth stack. About 150 lines of code across seven files.

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
}

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
}

Don't store refresh tokens in localStorage, they're as sensitive as passwords. HTTP-only cookies or secure native storage only.

OAuth (Google, GitHub, etc.)

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.

Common Pitfalls

Things that trip up NestJS auth setups:

  1. Storing JWT secrets in committed config: use environment variables and a secrets manager. 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 don't 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.

Beyond NestJS: A Simpler Approach

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.

Deploy with Encore

Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.

Deploy

Getting Started

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.

Deploy with Encore

Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.

Deploy

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.