NestJS JWT Authentication – End-to-End Production Guide

Table of contents
- 1. Install dependencies
- 2. Environment variables (.env)
- 3. Prisma schema (prisma/schema.prisma)
- 4. Create DTOs
- 5. Create decorators
- 6. JWT Guard with public-route support
- 7. Passport JWT Strategy
- 8. Refresh-token Strategy (optional but recommended)
- 9. Auth Service – register, login, refresh, logout
- 10. Auth Controller
- 11. Auth Module wiring
- 12. Global setup (main.ts)
- 13. Global guard registration
- 14. Run & test

This article walks you through every single file required to get a secure, refresh-token-enabled JWT authentication system in NestJS with Prisma, bcrypt, Passport, and class-validator.
Copy / paste the snippets in order and you will have a working project.
1. Install dependencies
npm i @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm i -D @types/passport-jwt @types/bcrypt
2. Environment variables (.env
)
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
JWT_SECRET="super-secret-access"
JWT_REFRESH_SECRET="super-secret-refresh"
JWT_EXPIRES="15m"
JWT_REFRESH_EXPIRES="7d"
This configuration file likely contains environment variables used by a web application.
DATABASE_URL
: This variable stores the connection information for the PostgreSQL database, including the username, password, host, port, and database name.JWT_SECRET
: This variable stores a secret key used for generating JSON Web Tokens (JWT) for authentication and authorization purposes. It should be kept private and secure to prevent unauthorized access.JWT_REFRESH_SECRET
: This variable stores a separate secret key used specifically for generating refresh tokens in a JWT-based authentication system. Refresh tokens are used to obtain new access tokens after they expire without requiring re-authentication.JWT_EXPIRES
: This variable specifies the expiration time for access tokens created by the application. In this case, it is set to 15 minutes, meaning that access tokens will be valid for 15 minutes before they expire.JWT_REFRESH_EXPIRES
: This variable specifies the expiration time for refresh tokens generated by the application. In this case, it is set to 7 days, allowing users to obtain new access tokens for a week without having to re-authenticate.
By using these environment variables, the application can securely store sensitive information such as database credentials and authentication secrets, without having to hardcode them in the codebase.
3. Prisma schema (prisma/schema.prisma
)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum RoleEnum {
IT_HEAD
SYSTEM_OWNER
FINANCE_OFFICER
REGISTRATION_CLERK
AUDITOR
VIEWER
}
model FncciUser {
id Int @id @default(autoincrement())
email String @unique
password String
role RoleEnum @default(VIEWER)
firstName String
lastName String
phoneNumber String
refreshToken String? // stores hashed refresh token
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
After editing:
npx prisma migrate dev --name init
npx prisma generate
4. Create DTOs
src/auth/dto/create-auth.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { RoleEnum } from '@prisma/client';
export class CreateAuthDto {
@ApiProperty({ example: 'john@doe.com' })
@IsEmail() @IsNotEmpty()
email: string;
@ApiProperty({ minLength: 8, maxLength: 20 })
@IsString() @MinLength(8) @MaxLength(20) @IsNotEmpty()
password: string;
@ApiProperty({ enum: RoleEnum, example: RoleEnum.VIEWER })
@IsEnum(RoleEnum) @IsNotEmpty()
role: RoleEnum;
@ApiProperty({ example: 'John' })
@IsString() @IsNotEmpty()
firstName: string;
@ApiProperty({ example: 'Doe' })
@IsString() @IsNotEmpty()
lastName: string;
@ApiProperty({ example: '+977-9801234567' })
@IsString() @IsNotEmpty()
phoneNumber: string;
}
src/auth/dto/login-auth.dto.ts
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginAuthDto {
@IsEmail() @IsNotEmpty()
email: string;
@IsString() @IsNotEmpty()
password: string;
}
5. Create decorators
src/decorator/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
6. JWT Guard with public-route support
src/auth/guard/jwt-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorator/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return isPublic ? true : super.canActivate(context);
}
}
7. Passport JWT Strategy
src/auth/strategy/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 config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>('JWT_SECRET'),
});
}
async validate(payload: any) {
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
8. Refresh-token Strategy (optional but recommended)
src/auth/strategy/jwt-refresh.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
secretOrKey: config.get<string>('JWT_REFRESH_SECRET'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: any) {
return { id: payload.sub, email: payload.email, role: payload.role, refreshToken: req.body.refreshToken };
}
}
9. Auth Service – register, login, refresh, logout
src/auth/auth.service.ts
import { ConflictException, ForbiddenException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { LoginAuthDto } from './dto/login-auth.dto';
@Injectable()
export class AuthService {
private readonly salt = 10;
constructor(
private prisma: PrismaService,
private jwt: JwtService,
) {}
async register(dto: CreateAuthDto) {
const exists = await this.prisma.fncciUser.findUnique({ where: { email: dto.email } });
if (exists) throw new ConflictException('Email already taken');
const hashed = await bcrypt.hash(dto.password, this.salt);
const user = await this.prisma.fncciUser.create({
data: { ...dto, password: hashed },
select: { id: true, email: true, role: true, firstName: true, lastName: true, phoneNumber: true, createdAt: true },
});
return user;
}
async login(dto: LoginAuthDto) {
const user = await this.prisma.fncciUser.findUnique({ where: { email: dto.email } });
if (!user) throw new NotFoundException('User not found');
const ok = await bcrypt.compare(dto.password, user.password);
if (!ok) throw new UnauthorizedException('Wrong password');
const payload = { sub: user.id, email: user.email, role: user.role };
const accessToken = this.jwt.sign(payload, { expiresIn: '15m' });
const refreshToken = this.jwt.sign(payload, { secret: process.env.JWT_REFRESH_SECRET, expiresIn: '7d' });
await this.prisma.fncciUser.update({ where: { id: user.id }, data: { refreshToken: await bcrypt.hash(refreshToken, this.salt) } });
return { accessToken, refreshToken };
}
async refreshTokens(refreshToken: string) {
try {
const payload = this.jwt.verify(refreshToken, { secret: process.env.JWT_REFRESH_SECRET });
const user = await this.prisma.fncciUser.findUnique({ where: { id: payload.sub } });
if (!user || !user.refreshToken) throw new ForbiddenException('Access Denied');
const rtMatches = await bcrypt.compare(refreshToken, user.refreshToken);
if (!rtMatches) throw new ForbiddenException('Access Denied');
const newPayload = { sub: user.id, email: user.email, role: user.role };
const newAccess = this.jwt.sign(newPayload, { expiresIn: '15m' });
const newRefresh = this.jwt.sign(newPayload, { secret: process.env.JWT_REFRESH_SECRET, expiresIn: '7d' });
await this.prisma.fncciUser.update({ where: { id: user.id }, data: { refreshToken: await bcrypt.hash(newRefresh, this.salt) } });
return { accessToken: newAccess, refreshToken: newRefresh };
} catch {
throw new ForbiddenException('Invalid refresh token');
}
}
async logout(userId: number) {
await this.prisma.fncciUser.update({ where: { id: userId }, data: { refreshToken: null } });
return { message: 'Logged out' };
}
}
10. Auth Controller
src/auth/auth.controller.ts
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards, ValidationPipe } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { LoginAuthDto } from './dto/login-auth.dto';
import { Public } from '../decorator/public.decorator';
import { Request } from 'express';
@Controller('auth')
export class AuthController {
constructor(private auth: AuthService) {}
@Public()
@Post('register')
async register(@Body(ValidationPipe) dto: CreateAuthDto) {
return this.auth.register(dto);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('login')
async login(@Body() dto: LoginAuthDto) {
return this.auth.login(dto);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('refresh')
async refresh(@Body('refreshToken') refreshToken: string) {
return this.auth.refreshTokens(refreshToken);
}
@Post('logout')
async logout(@Req() req: Request) {
const user = req.user as any;
return this.auth.logout(user.id);
}
}
11. Auth Module wiring
src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategy/jwt.strategy';
import { JwtRefreshStrategy } from './strategy/jwt-refresh.strategy';
@Module({
imports: [
PassportModule,
JwtModule.register({}),
PrismaModule,
],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
12. Global setup (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 }));
app.enableCors({ origin: true, credentials: true });
await app.listen(process.env.PORT || 3000);
}
bootstrap();
13. Global guard registration
src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/guard/jwt-auth.guard';
@Module({
imports: [AuthModule],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
14. Run & test
npm run start:dev
Endpoint | Public | Body | Response |
POST /auth/register | ✅ | CreateAuthDto | { id, email, … } |
POST /auth/login | ✅ | LoginAuthDto | { accessToken, refreshToken } |
POST /auth/refresh | ✅ | { refreshToken } | { accessToken, refreshToken } |
POST /auth/logout | ❌ | – | { message } |
All other routes automatically require a valid bearer token.
That’s the complete, production-ready JWT authentication system you can drop into any NestJS project.
Subscribe to my newsletter
Read articles from Kumar Chaudhary directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
