TechInsight

Create a Full Authentication System Using NestJS and Next.js with JWT and Refresh Tokens.

Authentication is the heart of almost every web application. whether it’s a social platform, an e-commerce app, or an admin dashboard.

Create a Full Authentication System Using NestJS and Next.js with JWT and Refresh Tokens.

In this article, we’ll build a complete authentication system using NestJS (backend) and Next.js (frontend) — implementing both Access Tokens and Refresh Tokens for secure session management.

What You’ll Learn

By the end of this tutorial, you’ll understand how to:

  • Build a NestJS Auth API with JWT and Refresh Tokens

  • Secure routes using Guards and Decorators

  • Store and validate tokens in HTTP-only cookies

  • Handle authentication on the Next.js frontend

  • Automatically refresh expired tokens

Tech Stack

Layer

Technology

Backend

NestJS, TypeScript, Passport.js, JWT

Frontend

Next.js 14 (App Router), React Hook Form, Axios

Database

PostgreSQL (with Prisma ORM)

Auth

Access Token + Refresh Token (JWT)

Deployment-ready

Works with any REST setup

Step 1 — Setting up the NestJS Backend

npm i -g @nestjs/cli
nest new auth-backend

2. Install Dependencies

npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt prisma @prisma/client cookie-parser

Step 2 — Configure Prisma (Database Layer)

Initialize Prisma:

npx prisma init

Then update your schema.prisma file:

model User {
id Int @id @default(autoincrement())
email String @unique
password String
refreshToken String?
createdAt DateTime @default(now())
}

Run migration:

npx prisma migrate dev --name init

Step 3 — Implement JWT Authentication in NestJS

Generate Auth Module

nest g module auth
nest g service auth
nest g controller auth

AuthService — Handle Signup, Login, Tokens

// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwt: JwtService) {}

async signup(email: string, password: string) {
const hash = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({
data: { email, password: hash },
});
return this.generateTokens(user.id, user.email);
}

async login(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.password))) {
throw new UnauthorizedException('Invalid credentials');
}
return this.generateTokens(user.id, user.email);
}

async generateTokens(userId: number, email: string) {
const accessToken = await this.jwt.signAsync(
{ sub: userId, email },
{ secret: process.env.JWT_SECRET, expiresIn: '15m' },
);
const refreshToken = await this.jwt.signAsync(
{ sub: userId },
{ secret: process.env.JWT_REFRESH_SECRET, expiresIn: '7d' },
);
await this.prisma.user.update({
where: { id: userId },
data: { refreshToken },
});
return { accessToken, refreshToken };
}
}

Step 4 — Auth Controller

// auth.controller.ts
import { Body, Controller, Post, Res } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Response } from 'express';

@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}

@Post('signup')
async signup(@Body() body: any, @Res() res: Response) {
const tokens = await this.authService.signup(body.email, body.password);
res.cookie('refreshToken', tokens.refreshToken, { httpOnly: true });
return res.json({ accessToken: tokens.accessToken });
}

@Post('login')
async login(@Body() body: any, @Res() res: Response) {
const tokens = await this.authService.login(body.email, body.password);
res.cookie('refreshToken', tokens.refreshToken, { httpOnly: true });
return res.json({ accessToken: tokens.accessToken });
}
}

Step 5 — Refresh Token Endpoint

@Post('refresh')
async refresh(@Req() req: Request, @Res() res: Response) {
const token = req.cookies['refreshToken'];
if (!token) throw new UnauthorizedException('No refresh token');

const payload = this.jwt.verify(token, { secret: process.env.JWT_REFRESH_SECRET });
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });

if (!user || user.refreshToken !== token) throw new UnauthorizedException('Invalid token');

const newTokens = await this.generateTokens(user.id, user.email);
res.cookie('refreshToken', newTokens.refreshToken, { httpOnly: true });
return res.json({ accessToken: newTokens.accessToken });
}

Step 6 — Next.js Frontend Setup

1. Create a Next.js project

npx create-next-app@latest auth-frontend

2. Setup Axios instance

// lib/axios.ts
import axios from 'axios';

export const api = axios.create({
baseURL: 'http://localhost:3000/auth',
withCredentials: true,
});

3. Create Login Page

"use client";

import { useState } from "react";
import { api } from "@/lib/axios";

export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [token, setToken] = useState("");

const handleLogin = async () => {
const res = await api.post("/login", { email, password });
setToken(res.data.accessToken);
};

return (
<div className="max-w-sm mx-auto mt-20 space-y-4">
<input className="border p-2 w-full" placeholder="Email" onChange={(e) => setEmail(e.target.value)} />
<input className="border p-2 w-full" type="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} />
<button onClick={handleLogin} className="bg-red-600 text-white w-full p-2 rounded">Login</button>
{token && <p className="text-green-600 break-all">Access Token: {token}</p>}
</div>
);
}

Step 7 — Auto Refresh Token (Frontend)

api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await api.post("/refresh");
return api(error.config);
}
return Promise.reject(error);
}
);

Step 8 — Protecting Routes in Next.js

Create a custom hook:

// useAuth.ts
import { useEffect } from "react";
import { useRouter } from "next/navigation";

export const useAuth = (token: string | null) => {
const router = useRouter();
useEffect(() => {
if (!token) router.push("/login");
}, [token]);
};

Use it in protected pages:

"use client";
import { useAuth } from "@/hooks/useAuth";

export default function Dashboard() {
const token = typeof window !== "undefined" ? localStorage.getItem("accessToken") : null;
useAuth(token);

return <div className="p-6">Welcome to your dashboard 🚀</div>;
}

Step 9 — Testing the Flow

  1. Run your NestJS backend on port 3000

  2. Start Next.js frontend on port 3001

  3. Try signing up, logging in, and refreshing the page

  4. Check that tokens are auto-refreshed and cookies are secure

Best Practices

  • Use HTTP-only cookies for refresh tokens

  • Keep short expiry for access tokens (15 min)

  • Rotate refresh tokens on every new login

  • Implement logout by deleting the refresh token in DB

  • In production, use HTTPS and CORS configuration

Conclusion

You’ve just built a complete, secure authentication system combining the power of NestJS and Next.js.

This stack gives you a scalable backend, a fast frontend, and secure token-based authentication ready for production.

Whether you’re building a SaaS app, eCommerce site, or admin dashboard, this architecture provides a solid foundation.

Comments

No comments yet.