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.

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-backend2. Install Dependencies
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt prisma @prisma/client cookie-parserStep 2 — Configure Prisma (Database Layer)
Initialize Prisma:
npx prisma initThen 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 initStep 3 — Implement JWT Authentication in NestJS
Generate Auth Module
nest g module auth
nest g service auth
nest g controller authAuthService — 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-frontend2. 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
Run your NestJS backend on port 3000
Start Next.js frontend on port 3001
Try signing up, logging in, and refreshing the page
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.