Editor’s note: This article was reviewed and updated by Rosario De Chiara on 3 August 2026 to modernize the NestJS JWT authentication flow, add DTO validation, improve password hashing guidance, and clarify the roles of guards, strategies, and protected routes.
Authentication is one of the most important parts of any backend application. Before an API returns protected data or allows a user to perform an account-specific action, it needs a reliable way to verify who that user is.
In NestJS, a common way to do this is with JSON Web Tokens (JWTs). The user signs in with a username and password, the server validates those credentials, and the application returns a signed access token. The client then sends that token with future requests, usually in the Authorization: Bearer <token> header, so protected routes can verify the user without checking the password again.
It is important to implement this flow carefully. OWASP lists identification and authentication failures among the top web application security risks, and weak authentication code can expose accounts, tokens, and sensitive user data.
In this tutorial, we’ll build a basic JWT authentication flow in a NestJS API. We’ll use SQLite and TypeORM for persistence, Passport strategies for local and JWT authentication, DTOs for request validation, and bcrypt for password hashing.
By the end, you’ll have the following endpoints:
| Endpoint | Method | Purpose | Auth required |
|---|---|---|---|
/users/signup |
POST |
Create a user account and hash the password | No |
/auth/login |
POST |
Validate credentials and return a JWT access token | No |
/auth/profile |
GET |
Return the authenticated user’s token payload | Yes |
Jump ahead:
The source code for this project is available in this GitHub repository. The application uses:
@nestjs/jwt to sign access tokensbcrypt to hash passwordsclass-validator and class-transformer to validate request DTOsThe example uses SQLite to keep local setup simple. In a production application, you could use another database, such as PostgreSQL, MySQL, or MongoDB, with the same general authentication flow.
NestJS is a server-side framework for building Node.js applications. It is written in TypeScript and provides a structured application architecture around modules, controllers, services, decorators, dependency injection, and guards.
NestJS is often compared to Angular because it uses similar concepts, including dependency injection and decorators. That structure makes it useful for APIs that need clear separation between routing, business logic, validation, persistence, and security.
For authentication specifically, Nest integrates well with Passport through @nestjs/passport. Passport handles authentication strategies, while Nest guards decide whether a given request can reach a route handler.
Before we write code, it helps to separate the authentication flow into three parts:
| Step | What happens | Main NestJS pieces |
|---|---|---|
| Signup | The API receives a username and password, hashes the password, and stores the user | UsersController, UsersService, DTO validation, bcrypt |
| Login | The API validates the username and password and returns a signed access token | LocalStrategy, LocalAuthGuard, AuthService, JwtService |
| Protected request | The API verifies the JWT and allows the request if the token is valid | JwtStrategy, JwtAuthGuard, protected controller route |
A JWT should not contain sensitive data like passwords. In this example, the token payload contains the user ID in the sub claim and the username. Protected routes can then identify the authenticated user from the verified token payload.
To run the project locally, clone the repository, install dependencies, and copy the example environment file:
npm install cp .env.example .env
On Windows, use copy .env.example .env instead of cp.
Next, start the application in watch mode:
nest start --watch
The application will restart automatically when you make changes, which is useful while building and testing the authentication flow.
To keep the application organized, create a dedicated users module:
nest g module users
This command creates a users folder with a users.module.ts file and updates app.module.ts for you.
Create a user.entity.ts file in the src/users/entities folder and add the following code:
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'users' })
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ unique: true })
username!: string;
@Column()
password!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
The @Entity() decorator tells TypeORM to map this class to a database table. The id field is generated as a UUID, username is unique, and password stores the hashed password. The createdAt and updatedAt fields are managed automatically by TypeORM.
Next, update users.module.ts so the User entity is available through TypeORM:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Exporting UsersService is important because the auth module will need to use it when validating login credentials.
Create the users service with the Nest CLI:
nest g service users
This command creates users.service.ts. You can create the file manually, but the CLI keeps the module wiring consistent.
Now add the following code to users.service.ts:
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async createUser(username: string, password: string): Promise<User> {
const existingUser = await this.findByUsername(username);
if (existingUser) {
throw new ConflictException('Username already exists');
}
const user = this.usersRepository.create({ username, password });
return this.usersRepository.save(user);
}
findByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } });
}
findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } });
}
}
Here, @InjectRepository(User) injects the TypeORM repository for the User entity. The service checks for duplicate usernames before creating a user, then exposes lookup methods that the auth flow will use later.
In NestJS, a DTO, or Data Transfer Object, defines the shape of data a route expects to receive. DTOs are useful because they give your controllers a clear request contract and work with Nest’s validation pipeline.
Generate an auth credentials DTO:
nest generate class auth/dto/auth-credentials.dto --no-spec
Then add the following code to auth/dto/auth-credentials.dto.ts:
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AuthCredentialsDto {
@IsString()
@IsNotEmpty()
username!: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
password!: string;
}
This DTO requires both username and password, and it enforces a minimum password length.
To make DTO validation run automatically, enable Nest’s ValidationPipe in 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,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
whitelist: true strips properties that are not defined in the DTO. forbidNonWhitelisted: true rejects requests that include unexpected fields. transform: true lets Nest transform incoming payloads into DTO instances.
Create a user controller to define the signup route:
nest g controller users
Add the following code to users.controller.ts:
import { Body, Controller, Post } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthCredentialsDto } from '../auth/dto/auth-credentials.dto';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
type SafeUser = Omit<User, 'password'>;
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post('signup')
async signUp(@Body() credentialsDto: AuthCredentialsDto): Promise<SafeUser> {
const hashedPassword = await bcrypt.hash(credentialsDto.password, 10);
const user = await this.usersService.createUser(
credentialsDto.username,
hashedPassword,
);
const { password, ...safeUser } = user;
return safeUser;
}
}
The controller receives the validated DTO, hashes the password with bcrypt, and delegates user creation to UsersService. Notice that the response removes the password field before returning the user. Even though the value is hashed, password hashes should not be returned to clients.
Create the auth module with the following command:
nest g module auth
This command creates an auth folder with an auth.module.ts file and updates app.module.ts.
Now we can add JWT support to the application. Install the required packages:
npm install @nestjs/jwt @nestjs/passport passport passport-local passport-jwt bcrypt @nestjs/config npm install --save-dev @types/passport-local @types/passport-jwt
Next, create auth/strategies/local.strategy.ts:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(username: string, password: string) {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
}
The local strategy handles username/password authentication. By default, passport-local expects username and password fields in the request body.
Next, create auth/strategies/jwt.strategy.ts:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: { sub: string; username: string }) {
return { userId: payload.sub, username: payload.username };
}
}
The JWT strategy extracts the bearer token from the Authorization header, verifies the signature and expiration, and returns a user object that Nest attaches to req.user.
Then replace the code in auth/auth.module.ts with the following:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule, JwtModuleOptions } 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 { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
imports: [
ConfigModule,
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService): JwtModuleOptions => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN') ?? '1h',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}
Using registerAsync() lets the JWT module read configuration from environment variables. Avoid hardcoding the JWT secret in source code. The repository includes an .env.example file with placeholder values.
A basic .env file might look like this:
JWT_SECRET=replace_this_with_a_long_random_secret JWT_EXPIRES_IN=1h
For production, use a long, randomly generated secret and store it in your deployment platform’s secret manager.
Create the auth service and controller:
nest generate service auth nest generate controller auth
Then open auth/auth.service.ts and add the following code:
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User } from '../users/entities/user.entity';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<Pick<User, 'id' | 'username'> | null> {
const user = await this.usersService.findByUsername(username);
if (!user) {
return null;
}
const passwordMatches = await bcrypt.compare(password, user.password);
if (!passwordMatches) {
return null;
}
return { id: user.id, username: user.username };
}
async login(user: Pick<User, 'id' | 'username'>) {
const payload = { username: user.username, sub: user.id };
return {
access_token: await this.jwtService.signAsync(payload),
};
}
}
validateUser() checks the username and compares the submitted password with the stored password hash. If the credentials are valid, it returns only the fields needed for the JWT payload. login() signs the payload and returns an access token.
Guards decide whether a request can reach a route handler. Create auth/guards/local-auth.guard.ts:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
Then create auth/guards/jwt-auth.guard.ts:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
The local guard runs the local strategy on the login route. The JWT guard runs the JWT strategy on protected routes.
Now add the following code to auth/auth.controller.ts:
import { Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { Request as ExpressRequest } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
type LocalUser = { id: string; username: string };
type JwtUser = { userId: string; username: string };
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('login')
login(@Req() req: ExpressRequest & { user: LocalUser }) {
return this.authService.login(req.user);
}
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() req: ExpressRequest & { user: JwtUser }) {
return req.user;
}
}
When a user sends valid credentials to /auth/login, the LocalAuthGuard runs LocalStrategy, which calls AuthService.validateUser(). Passport attaches the returned user object to req.user, and the controller passes it to AuthService.login().
When a user requests /auth/profile, the JwtAuthGuard checks the bearer token. If the token is valid, the route returns the token payload.
Start the application:
nest start --watch
Then test the signup route in Postman by sending a POST request to localhost:3000/users/signup with the following JSON body:
{
"username": "rosario",
"password": "secret123"
}

Next, test the login endpoint by sending a POST request to localhost:3000/auth/login with the same credentials:
{
"username": "rosario",
"password": "secret123"
}

If the username and password exist in the database, the API returns an access_token:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
To call the protected profile route, send a GET request to localhost:3000/auth/profile with this header:
Authorization: Bearer <access_token>
Here is the complete endpoint flow:
| Endpoint | Method | What it does |
|---|---|---|
/ |
GET |
Returns a basic greeting or health-check response |
/users/signup |
POST |
Creates a user account with a hashed password |
/auth/login |
POST |
Authenticates a user and returns a JWT access token |
/auth/profile |
GET |
Returns the authenticated user payload from the verified JWT |
The signup flow is straightforward: the client sends a username and password to /users/signup, the NestJS server hashes the password, stores the user in SQLite, and returns a 201 Created response.

The login flow starts when a registered user sends credentials to /auth/login. The local strategy validates the credentials, and the auth service returns a signed JWT.

Finally, the client sends that JWT to a protected endpoint.

For protected routes, the client and NestJS server are the main actors. The server does not need to check the user’s password again. Instead, it verifies the JWT signature and expiration. If the token is valid, the JWT strategy exposes the token payload to the route handler through req.user.
JWT authentication is easy to prototype, but several mistakes can make it unsafe in production:
password before returning a user object.HttpOnly, Secure, SameSite, and CSRF considerations.In this tutorial, we implemented JWT authentication in a NestJS API using Passport, TypeORM, SQLite, DTO validation, and bcrypt password hashing. The final application can create users, authenticate credentials, issue JWT access tokens, and protect routes with a JWT guard.
The key pattern is to keep each responsibility separate: the users module owns persistence, the auth service owns credential validation and token signing, the local strategy handles login, and the JWT strategy protects authenticated routes. That structure is what makes the authentication flow easier to test and extend.
For production, treat this as a foundation rather than a complete auth system. Add refresh tokens if users need long-lived sessions, enforce authorization rules for protected resources, store secrets outside source control, and consider whether headers or cookies are the safer token transport for your frontend architecture.
To learn more, refer to the official NestJS authentication documentation.
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

Vercel eve brings familiar Next.js file-based routing to AI agents. Discover how eve simplifies agent orchestration, sandboxing, and durable execution in this developer guide.

Discover how React Fiber works under the hood. Learn how React builds the DOM, handles concurrent rendering, and works alongside React 19 features and the new React Compiler.

Learn how to use Skybridge, an open-source React framework, to build and deploy cross-platform AI apps and interactive UI widgets for ChatGPT, Claude, and MCP clients from a single codebase.

Learn how to set up Meilisearch, index documents, and build keyword, semantic, and hybrid search with AI-powered retrieval.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now