NestJS JWT Authentication – End-to-End Production Guide

Kumar ChaudharyKumar Chaudhary
7 min read

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.

  1. DATABASE_URL: This variable stores the connection information for the PostgreSQL database, including the username, password, host, port, and database name.

  2. 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.

  3. 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.

  4. 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.

  5. 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 };
  }
}

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
EndpointPublicBodyResponse
POST /auth/registerCreateAuthDto{ id, email, … }
POST /auth/loginLoginAuthDto{ 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.

0
Subscribe to my newsletter

Read articles from Kumar Chaudhary directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Kumar Chaudhary
Kumar Chaudhary