Building secure authentication in Next.js applications can be challenging, especially with the framework's unique server-side and client-side rendering capabilities. In this comprehensive guide, I'll show you how to implement JWT (JSON Web Token) authentication in Next.js 14+ with App Router, covering everything from setup to production-ready security practices.
Why JWT Authentication for Next.js?
Next.js's hybrid rendering model (SSR, SSG, CSR) requires an authentication solution that works seamlessly across server and client components. JWT authentication provides:
Stateless Authentication: Perfect for serverless deployments and edge functions
API Route Protection: Secure your Next.js API routes effortlessly
SSR Compatibility: Works with server-side rendering out of the box
Scalability: No session storage needed, ideal for distributed systems
Mobile-Friendly: Easy integration with React Native or mobile apps
Edge-Ready: Compatible with Next.js Edge Runtime
What We'll Build
By the end of this tutorial, you'll have a fully functional Next.js app with:
- User registration and login
- Protected pages and API routes
- JWT token management with refresh tokens
- Middleware for authentication
- Secure cookie storage
- Logout functionality
- Client and server-side authentication checks
Prerequisites
Before we start, ensure you have:
- Node.js 18+ installed
- Basic understanding of React and Next.js
- Familiarity with TypeScript (optional but recommended)
- A backend API or we'll use Next.js API routes
Project Setup
Step 1: Create a Next.js Project
npx create-next-app@latest nextjs-jwt-auth
cd nextjs-jwt-auth
When prompted, select:
- ✅ TypeScript
- ✅ ESLint
- ✅ Tailwind CSS
- ✅ App Router
- ❌ Turbopack
Step 2: Install Required Dependencies
npm install jsonwebtoken bcryptjs jose
npm install --save-dev @types/jsonwebtoken @types/bcryptjs
Package Breakdown:
-
jsonwebtoken: JWT creation and verification -
bcryptjs: Password hashing -
jose: JWT handling for Edge Runtime (Next.js compatible)
Step 3: Project Structure
Create the following folder structure:
nextjs-jwt-auth/
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ ├── login/route.ts
│ │ │ ├── register/route.ts
│ │ │ ├── logout/route.ts
│ │ │ └── refresh/route.ts
│ │ └── protected/route.ts
│ ├── login/page.tsx
│ ├── register/page.tsx
│ ├── dashboard/page.tsx
│ ├── layout.tsx
│ └── page.tsx
├── lib/
│ ├── auth.ts
│ ├── db.ts
│ └── types.ts
├── middleware.ts
└── .env.local
Setting Up Environment Variables
Create a .env.local file in your project root:
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
NODE_ENV=development
Important: Generate strong secrets using:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Creating Type Definitions
Create lib/types.ts:
export interface User {
id: string;
email: string;
name: string;
password?: string;
createdAt: Date;
}
export interface JWTPayload {
userId: string;
email: string;
name: string;
iat?: number;
exp?: number;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
name: string;
email: string;
password: string;
}
export interface AuthResponse {
success: boolean;
message?: string;
user?: Omit<User, 'password'>;
accessToken?: string;
}
Building Authentication Utilities
Create lib/auth.ts:
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { JWTPayload } from './types';
const secretKey = process.env.JWT_SECRET!;
const refreshSecretKey = process.env.JWT_REFRESH_SECRET!;
const key = new TextEncoder().encode(secretKey);
const refreshKey = new TextEncoder().encode(refreshSecretKey);
// Generate Access Token
export async function generateAccessToken(payload: JWTPayload): Promise<string> {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(key);
}
// Generate Refresh Token
export async function generateRefreshToken(payload: JWTPayload): Promise<string> {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(refreshKey);
}
// Verify Access Token
export async function verifyAccessToken(token: string): Promise<JWTPayload | null> {
try {
const verified = await jwtVerify(token, key);
return verified.payload as JWTPayload;
} catch (error) {
return null;
}
}
// Verify Refresh Token
export async function verifyRefreshToken(token: string): Promise<JWTPayload | null> {
try {
const verified = await jwtVerify(token, refreshKey);
return verified.payload as JWTPayload;
} catch (error) {
return null;
}
}
// Get token from cookies
export async function getTokenFromCookies(): Promise<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get('accessToken');
return token?.value || null;
}
// Get token from request headers
export function getTokenFromHeaders(request: NextRequest): string | null {
const authHeader = request.headers.get('authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return null;
}
// Get current user from token
export async function getCurrentUser(request: NextRequest): Promise<JWTPayload | null> {
const cookieStore = await cookies();
const token = cookieStore.get('accessToken')?.value || getTokenFromHeaders(request);
if (!token) return null;
return await verifyAccessToken(token);
}
// Set auth cookies
export async function setAuthCookies(accessToken: string, refreshToken: string) {
const cookieStore = await cookies();
cookieStore.set('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 15, // 15 minutes
path: '/',
});
cookieStore.set('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
}
// Clear auth cookies
export async function clearAuthCookies() {
const cookieStore = await cookies();
cookieStore.delete('accessToken');
cookieStore.delete('refreshToken');
}
Simple User Database (In-Memory)
Create lib/db.ts:
import bcrypt from 'bcryptjs';
import { User } from './types';
// In production, use a real database (PostgreSQL, MongoDB, etc.)
const users: Map<string, User> = new Map();
export const db = {
// Find user by email
findUserByEmail: async (email: string): Promise<User | null> => {
for (const user of users.values()) {
if (user.email === email) {
return user;
}
}
return null;
},
// Find user by ID
findUserById: async (id: string): Promise<User | null> => {
return users.get(id) || null;
},
// Create new user
createUser: async (name: string, email: string, password: string): Promise<User> => {
const existingUser = await db.findUserByEmail(email);
if (existingUser) {
throw new Error('User already exists');
}
const hashedPassword = await bcrypt.hash(password, 10);
const userId = Date.now().toString();
const user: User = {
id: userId,
name,
email,
password: hashedPassword,
createdAt: new Date(),
};
users.set(userId, user);
return user;
},
// Verify password
verifyPassword: async (plainPassword: string, hashedPassword: string): Promise<boolean> => {
return await bcrypt.compare(plainPassword, hashedPassword);
},
// Remove password from user object
sanitizeUser: (user: User): Omit<User, 'password'> => {
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
},
};
Creating API Routes
Registration Route
Create app/api/auth/register/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { RegisterData } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
const body: RegisterData = await request.json();
const { name, email, password } = body;
// Validation
if (!name || !email || !password) {
return NextResponse.json(
{ success: false, message: 'All fields are required' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ success: false, message: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Create user
const user = await db.createUser(name, email, password);
const sanitizedUser = db.sanitizeUser(user);
return NextResponse.json(
{
success: true,
message: 'User registered successfully',
user: sanitizedUser,
},
{ status: 201 }
);
} catch (error: any) {
return NextResponse.json(
{ success: false, message: error.message || 'Registration failed' },
{ status: 400 }
);
}
}
Login Route
Create app/api/auth/login/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { generateAccessToken, generateRefreshToken, setAuthCookies } from '@/lib/auth';
import { LoginCredentials } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
const body: LoginCredentials = await request.json();
const { email, password } = body;
// Validation
if (!email || !password) {
return NextResponse.json(
{ success: false, message: 'Email and password are required' },
{ status: 400 }
);
}
// Find user
const user = await db.findUserByEmail(email);
if (!user) {
return NextResponse.json(
{ success: false, message: 'Invalid credentials' },
{ status: 401 }
);
}
// Verify password
const isValidPassword = await db.verifyPassword(password, user.password!);
if (!isValidPassword) {
return NextResponse.json(
{ success: false, message: 'Invalid credentials' },
{ status: 401 }
);
}
// Generate tokens
const payload = {
userId: user.id,
email: user.email,
name: user.name,
};
const accessToken = await generateAccessToken(payload);
const refreshToken = await generateRefreshToken(payload);
// Set cookies
await setAuthCookies(accessToken, refreshToken);
const sanitizedUser = db.sanitizeUser(user);
return NextResponse.json(
{
success: true,
message: 'Login successful',
user: sanitizedUser,
accessToken,
},
{ status: 200 }
);
} catch (error: any) {
return NextResponse.json(
{ success: false, message: error.message || 'Login failed' },
{ status: 500 }
);
}
}
Refresh Token Route
Create app/api/auth/refresh/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifyRefreshToken, generateAccessToken, setAuthCookies, generateRefreshToken } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refreshToken')?.value;
if (!refreshToken) {
return NextResponse.json(
{ success: false, message: 'No refresh token provided' },
{ status: 401 }
);
}
const payload = await verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json(
{ success: false, message: 'Invalid refresh token' },
{ status: 401 }
);
}
// Generate new tokens
const newAccessToken = await generateAccessToken({
userId: payload.userId,
email: payload.email,
name: payload.name,
});
const newRefreshToken = await generateRefreshToken({
userId: payload.userId,
email: payload.email,
name: payload.name,
});
// Set new cookies
await setAuthCookies(newAccessToken, newRefreshToken);
return NextResponse.json(
{
success: true,
message: 'Token refreshed successfully',
accessToken: newAccessToken,
},
{ status: 200 }
);
} catch (error: any) {
return NextResponse.json(
{ success: false, message: error.message || 'Token refresh failed' },
{ status: 500 }
);
}
}
Logout Route
Create app/api/auth/logout/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { clearAuthCookies } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
await clearAuthCookies();
return NextResponse.json(
{ success: true, message: 'Logged out successfully' },
{ status: 200 }
);
} catch (error: any) {
return NextResponse.json(
{ success: false, message: error.message || 'Logout failed' },
{ status: 500 }
);
}
}
Protected API Route Example
Create app/api/protected/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
const user = await getCurrentUser(request);
if (!user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
return NextResponse.json(
{
success: true,
message: 'This is protected data',
user,
data: {
secretInfo: 'Only authenticated users can see this',
timestamp: new Date().toISOString(),
},
},
{ status: 200 }
);
} catch (error: any) {
return NextResponse.json(
{ success: false, message: error.message || 'Server error' },
{ status: 500 }
);
}
}
Creating Middleware for Route Protection
Create middleware.ts in the root directory:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyAccessToken } from './lib/auth';
export async function middleware(request: NextRequest) {
const token = request.cookies.get('accessToken')?.value;
// Protected routes
const protectedPaths = ['/dashboard', '/profile', '/settings'];
const isProtectedPath = protectedPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
);
// Public routes that should redirect if authenticated
const authPaths = ['/login', '/register'];
const isAuthPath = authPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
);
if (isProtectedPath) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const user = await verifyAccessToken(token);
if (!user) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('accessToken');
response.cookies.delete('refreshToken');
return response;
}
}
if (isAuthPath && token) {
const user = await verifyAccessToken(token);
if (user) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: [
'/dashboard/:path*',
'/profile/:path*',
'/settings/:path*',
'/login',
'/register',
],
};
Building Frontend Components
Login Page
Create app/login/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function LoginPage() {
const router = useRouter();
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (data.success) {
router.push('/dashboard');
router.refresh();
} else {
setError(data.message || 'Login failed');
}
} catch (error) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">Email address</label>
<input
id="email"
name="email"
type="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<div className="text-sm text-center">
<Link href="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
Don't have an account? Register
</Link>
</div>
</form>
</div>
</div>
);
}
Register Page
Create app/register/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function RegisterPage() {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
}),
});
const data = await response.json();
if (data.success) {
router.push('/login?registered=true');
} else {
setError(data.message || 'Registration failed');
}
} catch (error) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="rounded-md shadow-sm space-y-4">
<input
type="text"
required
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Full Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<input
type="email"
required
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Email address"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<input
type="password"
required
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
<input
type="password"
required
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Confirm Password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
/>
</div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Register'}
</button>
<div className="text-sm text-center">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Already have an account? Sign in
</Link>
</div>
</form>
</div>
</div>
);
}
Protected Dashboard Page
Create app/dashboard/page.tsx:
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
interface UserData {
userId: string;
email: string;
name: string;
}
export default function DashboardPage() {
const router = useRouter();
const [userData, setUserData] = useState<UserData | null>(null);
const [protectedData, setProtectedData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProtectedData();
}, []);
const fetchProtectedData = async () => {
try {
const response = await fetch('/api/protected');
const data = await response.json();
if (data.success) {
setUserData(data.user);
setProtectedData(data.data);
}
} catch (error) {
console.error('Error fetching protected data:', error);
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
} catch (error) {
console.error('Logout error:', error);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-xl">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold">Dashboard</h1>
</div>
<div className="flex items-center">
<button
onClick={handleLogout}
className="ml-4 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Logout
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-2xl font-bold mb-4">Welcome, {userData?.name}!</h2>
<div className="space-y-2">
<p className="text-gray-600">
<span className="font-semibold">Email:</span> {userData?.email}
</p>
<p className="text-gray-600">
<span className="font-semibold">User ID:</span> {userData?.userId}
</p>
</div>
</div>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-xl font-bold mb-4">Protected Data</h3>
{protectedData && (
<div className="space-y-2">
<p className="text-gray-600">{protectedData.secretInfo}</p>
<p className="text-sm text-gray-500">
Last accessed: {new Date(protectedData.timestamp).toLocaleString()}
</p>
</div>
)}
</div>
</div>
</main>
</div>
);
}
Creating a Custom Auth Hook
Create lib/useAuth.ts:
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
interface User {
userId: string;
email: string;
name: string;
}
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const response = await fetch('/api/protected');
const data = await response.json();
if (data.success) {
setUser(data.user);
} else {
setUser(null);
}
} catch (error) {
setUser(null);
} finally {
setLoading(false);
}
};
const login = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
return { success: true };
}
return { success: false, message: data.message };
};
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
router.push('/login');
};
const refreshToken = async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
});
const data = await response.json();
return data.success;
} catch (error) {
return false;
}
};
return { user, loading, login, logout, refreshToken };
}
Testing Your Authentication
Start the Development Server
npm run dev
Test Flow:
-
Register: Navigate to
http://localhost:3000/register- Create a new account
- Should redirect to login
-
Login: Go to
http://localhost:3000/login- Use your credentials
- Should redirect to dashboard
-
Dashboard: Access
http://localhost:3000/dashboard- See your user information
- View protected data
-
Logout: Click logout button
- Should redirect to login
- Cannot access dashboard
Security Best Practices
1. Token Storage
✅ DO:
- Store tokens in httpOnly cookies (server-side)
- Use secure flag in production
- Set appropriate sameSite attribute
❌ DON'T:
- Store tokens in localStorage
- Store tokens in regular cookies accessible via JavaScript
- Expose tokens in URLs
2. Token Expiration
// Short-lived access tokens
ACCESS_TOKEN_EXPIRY=15m
// Long-lived refresh tokens
REFRESH_TOKEN_EXPIRY=7d
3. HTTPS in Production
Always use HTTPS in production to prevent token interception:
cookieStore.set('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax',
maxAge: 60 * 15,
path: '/',
});
4. CSRF Protection
Next.js provides built-in CSRF protection for API routes. Ensure you're using:
export const config = {
api: {
bodyParser: true,
},
};
5. Rate Limiting
Install and configure rate limiting:
npm install @upstash/ratelimit @upstash/redis
6. Environment Variables
Never commit .env.local to version control:
# .gitignore
.env.local
.env.*.local
Production Deployment Checklist
- [ ] Generate strong JWT secrets
- [ ] Enable HTTPS/SSL
- [ ] Set secure cookie flags
- [ ] Configure CORS properly
- [ ] Implement rate limiting
- [ ] Set up monitoring and logging
- [ ] Use environment variables
- [ ] Enable token refresh mechanism
- [ ] Implement token blacklisting (optional)
- [ ] Add CSP headers
- [ ] Configure proper error handling
- [ ] Set up database (replace in-memory storage)
Connecting to a Real Database
Replace the in-memory storage with a real database. Here's an example with Prisma:
npm install prisma @prisma/client
npx prisma init
Update prisma/schema.prisma:
model User {
id String @id @default(cuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run migrations:
npx prisma migrate dev --name init
npx prisma generate
Advanced Features to Implement
1. Email Verification
Add email verification during registration to ensure valid users.
2. Password Reset
Implement forgot password functionality with time-limited reset tokens.
3. Two-Factor Authentication
Add an extra security layer with 2FA using libraries like speakeasy.
4. Social Authentication
Integrate OAuth providers using NextAuth.js:
npm install next-auth
5. Role-Based Access Control
Extend JWT payload with user roles:
export interface JWTPayload {
userId: string;
email: string;
name: string;
role: 'admin' | 'user' | 'moderator';
}
Common Issues and Solutions
Issue 1: "Invalid Token" After Refresh
Solution: Implement automatic token refresh in your API calls.
Issue 2: Cookies Not Being Set
Solution: Check cookie settings and ensure domain matches.
Issue 3: Middleware Not Working
Solution: Verify middleware.ts is in the root directory and config matcher is correct.
Issue 4: CORS Errors
Solution: Configure CORS headers in next.config.js:
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
];
},
};
Performance Optimization
1. Token Caching
Cache verified tokens to reduce computation:
const tokenCache = new Map<string, { payload: JWTPayload; exp: number }>();
2. Edge Runtime
Deploy middleware to Edge for faster authentication checks:
export const config = {
matcher: ['/dashboard/:path*'],
runtime: 'edge',
};
3. Lazy Loading
Lazy load authentication check on client:
const { user } = useAuth();
Monitoring and Logging
Implement logging for security events:
// lib/logger.ts
export const logger = {
login: (email: string, success: boolean) => {
console.log(`Login attempt: ${email} - ${success ? 'SUCCESS' : 'FAILED'}`);
},
tokenRefresh: (userId: string) => {
console.log(`Token refreshed for user: ${userId}`);
},
logout: (userId: string) => {
console.log(`User logged out: ${userId}`);
},
};
Conclusion
You've successfully implemented a complete JWT authentication system in Next.js with the App Router. This implementation includes user registration, login, logout, token refresh, protected routes, and middleware protection.
The stateless nature of JWT makes it perfect for Next.js applications, especially when deploying to serverless or edge environments. Remember to follow security best practices, use HTTPS in production, and keep your tokens short-lived.
Have questions about Next.js JWT authentication? Drop them in the comments below!
Found this helpful? Give it a like and follow for more Next.js tutorials!
Top comments (0)