Back to blog
authentication·7 min read

How to Add JWT Authentication in NestJS (Step-by-Step Guide)

A complete walkthrough of building JWT authentication in a NestJS app — install dependencies, hash passwords with bcrypt, build login/register endpoints, configure JwtModule, and protect routes with guards.

Khatab Wedaa

Khatab Wedaa

Software Engineer · Nestbolt

How to Add JWT Authentication in NestJS (Step-by-Step Guide)

JSON Web Tokens (JWT) are the most common way to authenticate users in a NestJS API. They're stateless, easy to verify, and play nicely with mobile clients, SPAs, and server-rendered apps alike. This guide walks through adding JWT authentication to a NestJS application from scratch — installing the right packages, hashing passwords, signing tokens, and protecting routes with a guard.

We'll use @nestjs/jwt, @nestjs/passport, passport-jwt, and bcrypt. All code is TypeScript and works on NestJS 10+.

What you'll build

By the end of this tutorial, your NestJS app will support:

  • POST /auth/register — create a user with a hashed password
  • POST /auth/login — verify credentials and return a JWT
  • A reusable JwtAuthGuard to protect any route
  • A request-scoped req.user populated by Passport on every protected request

Prerequisites

  • Node.js 18 or later
  • A NestJS 10+ project (run nest new my-app if you don't have one)
  • A way to persist users — TypeORM, Prisma, or Mongoose. The examples below use TypeORM, but the auth flow is the same.

1. Install dependencies

npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt class-validator class-transformer
npm install --save-dev @types/passport-jwt @types/bcrypt

What each package does:

  • @nestjs/jwt — wraps jsonwebtoken for signing and verifying tokens.
  • @nestjs/passport + passport + passport-jwt — Passport.js integration so NestJS guards can validate Bearer tokens.
  • bcrypt — password hashing. Never store plain-text passwords.
  • class-validator + class-transformer — DTO validation for incoming request bodies.

2. Configure the JwtModule

Register JwtModule once at the application root using registerAsync so the secret can come from environment variables:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthModule } from "./auth/auth.module";
 
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    JwtModule.registerAsync({
      global: true,
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        secret: config.getOrThrow<string>("JWT_SECRET"),
        signOptions: { expiresIn: "1h" },
      }),
      inject: [ConfigService],
    }),
    AuthModule,
  ],
})
export class AppModule {}

Set JWT_SECRET in your .env file. Use at least 32 random bytes — never commit a real secret to source control.

JWT_SECRET="paste-32-random-bytes-here"

3. Define a User entity

Any persistence layer works. Here's the TypeORM version:

// src/users/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
 
@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;
 
  @Column({ unique: true })
  email!: string;
 
  @Column()
  passwordHash!: string;
}

The important rule: store passwordHash, never password.

4. Build the AuthService

This is the heart of the system. It handles registration (hash + insert) and login (verify + sign).

// src/auth/auth.service.ts
import {
  ConflictException,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
import { Repository } from "typeorm";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "../users/user.entity";
 
const SALT_ROUNDS = 12;
 
@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User) private readonly users: Repository<User>,
    private readonly jwt: JwtService,
  ) {}
 
  async register(email: string, password: string) {
    const existing = await this.users.findOne({ where: { email } });
    if (existing) throw new ConflictException("Email already in use");
 
    const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
    const user = await this.users.save({ email, passwordHash });
 
    return this.signToken(user.id, user.email);
  }
 
  async login(email: string, password: string) {
    const user = await this.users.findOne({ where: { email } });
    if (!user) throw new UnauthorizedException("Invalid credentials");
 
    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) throw new UnauthorizedException("Invalid credentials");
 
    return this.signToken(user.id, user.email);
  }
 
  private async signToken(sub: string, email: string) {
    const accessToken = await this.jwt.signAsync({ sub, email });
    return { accessToken };
  }
}

Two details worth calling out:

  • Same error message for "user not found" and "wrong password." Returning a different message leaks which emails are registered. Always respond with Invalid credentials.
  • SALT_ROUNDS = 12 is the sweet spot for 2026. 10 is fast but increasingly weak; 14 is paranoid and noticeably slow on logins.

5. Add the AuthController

Validate input with DTOs and call the service:

// src/auth/dto/auth.dto.ts
import { IsEmail, MinLength } from "class-validator";
 
export class AuthDto {
  @IsEmail()
  email!: string;
 
  @MinLength(8)
  password!: string;
}
// src/auth/auth.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { AuthDto } from "./dto/auth.dto";
 
@Controller("auth")
export class AuthController {
  constructor(private readonly auth: AuthService) {}
 
  @Post("register")
  register(@Body() dto: AuthDto) {
    return this.auth.register(dto.email, dto.password);
  }
 
  @Post("login")
  login(@Body() dto: AuthDto) {
    return this.auth.login(dto.email, dto.password);
  }
}

Make sure ValidationPipe is enabled globally in main.ts:

// src/main.ts
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(3000);
}
bootstrap();

6. Create the JWT strategy

Passport's JwtStrategy extracts the Bearer token, verifies the signature, and hands you a decoded payload. NestJS attaches whatever validate() returns to req.user:

// src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
 
interface JwtPayload {
  sub: string;
  email: string;
}
 
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.getOrThrow<string>("JWT_SECRET"),
    });
  }
 
  validate(payload: JwtPayload) {
    if (!payload.sub) throw new UnauthorizedException();
    return { id: payload.sub, email: payload.email };
  }
}

7. Build a JwtAuthGuard

This is just a thin wrapper around AuthGuard("jwt"). Putting it in its own class lets you customise the error response or add logging later without touching every controller.

// src/auth/jwt-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
 
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

8. Wire the AuthModule

// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "../users/user.entity";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
 
@Module({
  imports: [
    PassportModule.register({ defaultStrategy: "jwt" }),
    TypeOrmModule.forFeature([User]),
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

9. Protect a route

Drop the guard on any controller method that should require a logged-in user:

// src/profile/profile.controller.ts
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
 
@Controller("profile")
export class ProfileController {
  @UseGuards(JwtAuthGuard)
  @Get()
  me(@Req() req: Request) {
    return req.user; // populated by JwtStrategy.validate()
  }
}

A request without a valid Authorization: Bearer <token> header now returns 401 Unauthorized automatically.

Test it end-to-end

# 1. Register
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correcthorsebatterystaple"}'
# → { "accessToken": "eyJhbGciOi..." }
 
# 2. Use the token
curl http://localhost:3000/profile \
  -H "Authorization: Bearer eyJhbGciOi..."
# → { "id": "...", "email": "alice@example.com" }

Common pitfalls

PitfallFix
Storing JWT_SECRET in codeAlways read from environment. Generate with openssl rand -base64 32.
Bcrypt rounds set to 4 or 8Use 12. The whole point of bcrypt is to be slow.
Different errors for "wrong password" vs "user not found"Return a single Invalid credentials message — leaking enumeration is a real attack.
Forgetting ignoreExpiration: falseDefault is false, but make sure you don't override it. Expired tokens must be rejected.
Storing the JWT in localStorage from the browserPrefer httpOnly, secure, sameSite=strict cookies. localStorage is XSS-readable.
No refresh token strategyFor long sessions, issue a short-lived access token (15min) plus a long-lived refresh token stored server-side.

Going further: skip rolling your own auth

What you've built above is a clean foundation, but a real production app usually needs more than just login. You'll quickly want:

  • Password reset via email
  • Email verification on signup
  • Two-factor authentication (TOTP)
  • Re-authentication for sensitive actions
  • Rate limiting on /auth/login
  • Profile management endpoints
  • A consistent way to revoke compromised tokens

Building all of that from scratch is a multi-week project, and the pieces interact in subtle ways — most security bugs in homegrown auth come from the interactions, not the individual features.

@nestbolt/authentication ships exactly this. Same JWT under the hood, frontend-agnostic, database-agnostic, and you opt in only to the features you need:

import { AuthenticationModule, Feature } from "@nestbolt/authentication";
 
@Module({
  imports: [
    AuthenticationModule.forRoot({
      features: [
        Feature.REGISTRATION,
        Feature.RESET_PASSWORDS,
        Feature.EMAIL_VERIFICATION,
        Feature.TWO_FACTOR_AUTHENTICATION,
      ],
      userRepository: TypeOrmUserRepository,
      jwtSecret: process.env.JWT_SECRET!,
      appName: "MyApp",
    }),
  ],
})
export class AppModule {}

It's MIT-licensed, fully typed, and well-tested. If you'd rather spend your week on the product than on auth plumbing, start with the quick start →

Wrapping up

You now have working JWT authentication in NestJS:

  1. JwtModule configured from environment
  2. Bcrypt-hashed passwords stored on a User entity
  3. register and login endpoints with DTO validation
  4. A Passport JWT strategy that populates req.user
  5. A reusable JwtAuthGuard to protect any route

This pattern scales well for small to medium apps. When you outgrow it — when product requirements push you into 2FA, password reset, email verification, and the rest — @nestbolt/authentication is the drop-in upgrade path that keeps the same JWT model you already understand.

Khatab Wedaa

Written by Khatab Wedaa

Software Engineer · Nestbolt

Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.