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
Software Engineer · Nestbolt
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 passwordPOST /auth/login— verify credentials and return a JWT- A reusable
JwtAuthGuardto protect any route - A request-scoped
req.userpopulated by Passport on every protected request
Prerequisites
- Node.js 18 or later
- A NestJS 10+ project (run
nest new my-appif 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/bcryptWhat each package does:
@nestjs/jwt— wrapsjsonwebtokenfor 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 = 12is 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
| Pitfall | Fix |
|---|---|
Storing JWT_SECRET in code | Always read from environment. Generate with openssl rand -base64 32. |
| Bcrypt rounds set to 4 or 8 | Use 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: false | Default is false, but make sure you don't override it. Expired tokens must be rejected. |
Storing the JWT in localStorage from the browser | Prefer httpOnly, secure, sameSite=strict cookies. localStorage is XSS-readable. |
| No refresh token strategy | For 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:
JwtModuleconfigured from environment- Bcrypt-hashed passwords stored on a User entity
registerandloginendpoints with DTO validation- A Passport JWT strategy that populates
req.user - A reusable
JwtAuthGuardto 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.
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.