JWT Authentication in NestJS: Complete Guide with Real Code
Learn how to implement JWT authentication in NestJS from scratch — registration, login, protected routes, and Guards — with real working code every step of the way.
Introduction To Nest JS
Part 3 of 6
Authentication is the thing that turns a toy project into a real one.
And honestly, it’s where a lot of developers get stuck with NestJS. It’s not because NestJS makes it hard, but it actually makes it pretty clean once you understand the pieces. But the docs assume you already know Passport.js, and most tutorials skip straight to the “add this decorator and it works” part without explaining what’s actually happening under the hood.
This article doesn’t do that. We’re building JWT auth from scratch — registration, login, token generation, and protected routes — and I’ll explain every piece as we go.
Prerequisites: This builds on the previous two articles in this series. You should understand NestJS modules, controllers, and services before continuing. You don’t need prior Passport.js experience.
What we’re building: A working auth system with user registration, login that returns a JWT, and route protection using Guards. We’ll use
@nestjs/jwtand@nestjs/passportwith thepassport-jwtstrategy. Database integration is kept simple (in-memory) so we can focus on the auth logic — swapping in TypeORM or Prisma is straightforward once this foundation is in place.
How JWT Auth Works in NestJS (The Mental Model First)
Before touching any code, let me give you the flow. This is what we’re building:
1. User registers → we hash their password and save it
2. User logs in → we verify their password, then issue a JWT
3. User makes a protected request → they send the JWT in the Authorization header
4. NestJS validates the token → if valid, the request goes through. If not, 401.
In NestJS, step 4 is handled by a Guard. Think of a Guard as a bouncer at the door — every request passes through it, and it decides whether to let the request continue or reject it outright.
If you’ve used Express, a Guard is like auth middleware, but with a clearer purpose and much better TypeScript support.
Project Setup
Let’s start fresh. If you already have a NestJS project, skip this.
npm i -g @nestjs/cli
nest new nestjs-jwt-auth
cd nestjs-jwt-auth
Now install the packages we need:
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt
Here’s what each one does:
@nestjs/jwt— NestJS wrapper for thejsonwebtokenlibrary@nestjs/passport— NestJS integration for Passport.jspassport— the underlying auth library NestJS integrates withpassport-jwt— the Passport strategy for validating JWTsbcrypt— for hashing passwords (never store plain text passwords)
Step 1: Create the Users Module
We need somewhere to store users. Let’s generate the module:
nest generate module users
nest generate service users
We’ll keep the user model simple for now:
// users/user.interface.ts
export interface User {
id: string;
name: string;
email: string;
password: string; // this will always be a bcrypt hash — never plain text
}
Now the service. This is where users get created and looked up:
// users/users.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { User } from './user.interface';
@Injectable()
export class UsersService {
// In-memory store — swap this for a real DB later
private users: User[] = [];
async findByEmail(email: string): Promise<User | undefined> {
return this.users.find(user => user.email === email);
}
async findById(id: string): Promise<User | undefined> {
return this.users.find(user => user.id === id);
}
async create(name: string, email: string, hashedPassword: string): Promise<User> {
const existing = await this.findByEmail(email);
if (existing) {
// 409 Conflict — this email is already registered
throw new ConflictException('An account with this email already exists');
}
const user: User = {
id: crypto.randomUUID(),
name,
email,
password: hashedPassword,
};
this.users.push(user);
return user;
}
}
And the module — make sure to export UsersService so AuthModule can use it:
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService], // AuthModule needs this
})
export class UsersModule {}
Step 2: Create the Auth Module
nest generate module auth
nest generate controller auth
nest generate service auth
The auth module is where the real auth logic lives. Generate a .env file first — we never hardcode secrets:
# .env
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=7d
Now install @nestjs/config to read it:
npm install @nestjs/config
The DTOs
Two DTOs — one for registration, one for login:
// auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsString()
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
}
// auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
The Auth Service
This is the core of everything. Registration hashes the password, login verifies it and returns a token:
// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async register(dto: RegisterDto) {
// Hash the password — never store plain text
// 10 is the salt rounds — higher is more secure but slower
const hashedPassword = await bcrypt.hash(dto.password, 10);
const user = await this.usersService.create(dto.name, dto.email, hashedPassword);
// Don't return the password hash — ever
const { password, ...result } = user;
return result;
}
async login(dto: LoginDto) {
const user = await this.usersService.findByEmail(dto.email);
// Use a vague error message — don't tell attackers whether the email exists
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordMatches = await bcrypt.compare(dto.password, user.password);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid credentials');
}
// The JWT payload — keep it small, this gets encoded in every token
const payload = { sub: user.id, email: user.email };
return {
access_token: await this.jwtService.signAsync(payload),
user: {
id: user.id,
name: user.name,
email: user.email,
},
};
}
async validateUser(userId: string) {
// Called by the JWT strategy on every protected request
return this.usersService.findById(userId);
}
}
Two things worth highlighting here.
The vague error message on login: 'Invalid credentials' covers both “email not found” and “wrong password.” If you send different errors for each case, an attacker can enumerate valid emails in your system. One generic message for both.
Stripping the password from the register response: We destructure password out and return result. Accidentally leaking a bcrypt hash in an API response is a bad day — get in the habit of never including it.
The JWT Strategy
This is the Passport strategy that validates incoming tokens on protected routes:
// 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';
import { AuthService } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly authService: AuthService,
configService: ConfigService,
) {
super({
// Extract the token from the Authorization: Bearer <token> header
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: { sub: string; email: string }) {
// payload is the decoded JWT — sub is the user id we put in during login
const user = await this.authService.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
// Whatever we return here gets attached to req.user
const { password, ...result } = user;
return result;
}
}
The validate() method runs automatically on every protected request. Passport calls it after verifying the token signature. What you return here gets attached to request.user — so inside any protected controller, req.user is the authenticated user.
The Auth Guard
The Guard is what actually protects routes. We define it once and apply it wherever we want:
// auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
That’s genuinely the whole file. AuthGuard('jwt') wires up to the JwtStrategy we just defined and handles all the validation logic for us.
The Auth Controller
// auth/auth.controller.ts
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register') // POST /auth/register
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login') // POST /auth/login
@HttpCode(200) // override default 201 — login isn't creating a resource
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
}
The Auth Module
Now wire everything together:
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule, // gives us access to UsersService
PassportModule,
ConfigModule,
JwtModule.registerAsync({
// registerAsync lets us inject ConfigService to read the secret from .env
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<string>('JWT_EXPIRES_IN') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
Step 3: Update AppModule
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // makes ConfigService available everywhere
AuthModule,
UsersModule,
],
})
export class AppModule {}
And add ValidationPipe globally in main.ts:
// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
// whitelist: true strips any properties not in your DTO — a security win
await app.listen(3000);
}
bootstrap();
Step 4: Protecting Routes with the Guard
Now the payoff. Any route you want to protect, just add @UseGuards(JwtAuthGuard):
// users/users.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me') // GET /api/users/me — protected
@UseGuards(JwtAuthGuard)
getMe(@Req() req: Request) {
// req.user is set by our JwtStrategy.validate() method
return req.user;
}
}
You can also protect an entire controller at once by putting the decorator on the class instead of the method:
@Controller('users')
@UseGuards(JwtAuthGuard) // every route in this controller is now protected
export class UsersController {}
Testing It
Start the server:
npm run start:dev
Register:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Brandford", "email": "brandford@example.com", "password": "securepass123"}'
Login:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "brandford@example.com", "password": "securepass123"}'
# Response:
# { "access_token": "eyJhbGci...", "user": { "id": "...", "name": "Brandford", ... } }
Access a protected route:
curl http://localhost:3000/api/users/me \
-H "Authorization: Bearer eyJhbGci..."
Send a request without the token and you’ll get a 401. Send a tampered token and you’ll get a 401. Send an expired token — 401. The Guard handles all of it.
Common Mistakes With JWT Auth in NestJS
Hardcoding the JWT secret
// ❌ Never do this
secret: 'mysecret123'
// ✅ Always read from environment
secret: configService.get<string>('JWT_SECRET')
If that secret ends up in version control, every token you’ve ever issued is compromised.
Forgetting JwtStrategy in providers
The strategy is an @Injectable() class — it needs to be in the providers array of AuthModule. Forget it and the Guard silently fails to work.
Putting sensitive data in the JWT payload
The JWT payload is base64 encoded, not encrypted. Anyone can decode it — they just can’t tamper with it without invalidating the signature. Don’t put passwords, full user objects, or anything sensitive in the payload.
// ❌ Too much — and password hash in a JWT is a serious mistake
const payload = { sub: user.id, email: user.email, password: user.password, role: user.role };
// ✅ Just what you need to identify the user
const payload = { sub: user.id, email: user.email };
What’s Next
You now have a working JWT auth system. Users can register, log in, and access protected routes. The foundation is solid.
The next step in this series is role-based access control — how to restrict certain routes to admins only, and how NestJS handles that cleanly with custom decorators and Guards. [TODO: link when published]
Drop your questions in the comments. If something didn’t work or a step wasn’t clear, let me know and I’ll update the article.
Build JWT authentication in NestJS from scratch — registration, login, token generation, and protected routes with Guards. Real code, no hand-waving.
Continue the series →
Next up: NestJS Modules, Controllers and Services — the architecture finally explained.
Read next article →Related Posts
NestJS Modules, Controllers, and Services: The Architecture Finally Explained
Confused by NestJS modules, controllers, and services? This guide breaks down the core architecture in plain terms, with real code examples and analogies that actually stick.
NestJS for Express Developers: What Actually Changes
Already know Express? Learn how NestJS builds on top of it with structure, TypeScript-first design, and built-in architecture — without throwing away what you already know.
NestJS + PostgreSQL with TypeORM: Complete Setup Guide
Learn how to connect NestJS to PostgreSQL using TypeORM — from installation to entities, relations, and repositories. With real code and the mistakes you'll actually make.