Authentication is a critical component of modern web applications. This comprehensive guide walks you through implementing secure JWT (JSON Web Token) authentication in Next.js, covering everything from basic setup to production-ready patterns.
Why JWT Authentication?
JWT authentication has become the standard for modern web applications because it's:
- Stateless: No need to store sessions on the server
- Scalable: Works seamlessly across multiple servers
- Portable: Can be used across different platforms and domains
- Secure: Cryptographically signed to prevent tampering
- Self-contained: Carries user information in the token itself
Understanding JWT Structure
A JWT consists of three parts separated by dots:
header.payload.signature
- Header: Contains token type and signing algorithm
- Payload: Contains user data (claims) like user ID, email, role
- Signature: Ensures the token hasn't been tampered with
Setting Up Your Next.js Project
First, create a new Next.js project and install required dependencies:
npx create-next-app@latest jwt-auth-app
cd jwt-auth-app
npm install jose bcryptjs
npm install -D @types/bcryptjs
jose: Modern JWT library for signing and verifying tokens
bcryptjs: For hashing passwords securely
Project Structure
src/
βββ app/
β βββ api/
β β βββ auth/
β β β βββ login/route.ts
β β β βββ register/route.ts
β β β βββ logout/route.ts
β β βββ protected/route.ts
β βββ login/page.tsx
β βββ dashboard/page.tsx
β βββ layout.tsx
βββ lib/
β βββ auth.ts
β βββ db.ts
βββ middleware.ts
Creating Authentication Utilities
Create src/lib/auth.ts for JWT operations:
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
const secret = new TextEncoder().encode(
process.env.JWT_SECRET || 'your-secret-key-min-32-chars'
);
export async function createToken(payload: any) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch (error) {
return null;
}
}
export async function getSession() {
const cookieStore = await cookies();
const token = cookieStore.get('token')?.value;
if (!token) return null;
return await verifyToken(token);
}
Building the Registration API
Create src/app/api/auth/register/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { createToken } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
const { email, password, name } = await request.json();
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Check if user exists (replace with your DB logic)
// const existingUser = await db.user.findUnique({ where: { email } });
// if (existingUser) {
// return NextResponse.json(
// { error: 'User already exists' },
// { status: 409 }
// );
// }
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user in database (pseudo-code)
const user = {
id: Date.now().toString(),
email,
name,
password: hashedPassword,
};
// await db.user.create({ data: user });
// Create JWT token
const token = await createToken({
userId: user.id,
email: user.email,
name: user.name,
});
// Set HTTP-only cookie
const response = NextResponse.json(
{
message: 'Registration successful',
user: { id: user.id, email: user.email, name: user.name }
},
{ status: 201 }
);
response.cookies.set('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return response;
} catch (error) {
return NextResponse.json(
{ error: 'Registration failed' },
{ status: 500 }
);
}
}
Building the Login API
Create src/app/api/auth/login/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { createToken } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password required' },
{ status: 400 }
);
}
// Find user in database (pseudo-code)
// const user = await db.user.findUnique({ where: { email } });
const user = {
id: '1',
email: 'user@example.com',
name: 'John Doe',
password: await bcrypt.hash('password123', 10),
};
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Create JWT token
const token = await createToken({
userId: user.id,
email: user.email,
name: user.name,
});
// Set cookie and return response
const response = NextResponse.json({
message: 'Login successful',
user: { id: user.id, email: user.email, name: user.name }
});
response.cookies.set('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
return response;
} catch (error) {
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}
Implementing Middleware Protection
Create src/middleware.ts to protect routes:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
// Protected routes
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
if (isProtectedRoute) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const payload = await verifyToken(token);
if (!payload) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('token');
return response;
}
}
// Redirect logged-in users away from auth pages
if (token && request.nextUrl.pathname === '/login') {
const payload = await verifyToken(token);
if (payload) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
Creating Login Page
Create src/app/login/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
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({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
router.push('/dashboard');
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-3xl font-bold text-center">Sign in</h2>
{error && (
<div className="bg-red-50 text-red-500 p-3 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
Creating Protected Dashboard
Create src/app/dashboard/page.tsx:
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect('/login');
}
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-lg">Welcome, {session.name}!</p>
<p className="text-gray-600">Email: {session.email}</p>
</div>
</div>
</div>
);
}
Implementing Logout
Create src/app/api/auth/logout/route.ts:
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({
message: 'Logged out successfully'
});
response.cookies.delete('token');
return response;
}
Security Best Practices
1. Environment Variables
Store sensitive data in .env.local:
JWT_SECRET=your-super-secret-key-minimum-32-characters-long
DATABASE_URL=your-database-connection-string
2. Token Storage
- Use HTTP-only cookies to prevent XSS attacks
- Set secure flag in production
- Use sameSite to prevent CSRF attacks
3. Password Security
- Always hash passwords with bcrypt (minimum 10 rounds)
- Never log or expose passwords
- Implement password strength requirements
4. Token Expiration
- Set reasonable expiration times (7 days recommended)
- Implement refresh token mechanism for better UX
- Clear expired tokens promptly
5. Rate Limiting
Implement rate limiting to prevent brute force attacks:
// Add to your API routes
const rateLimit = new Map();
function checkRateLimit(identifier: string) {
const now = Date.now();
const attempts = rateLimit.get(identifier) || [];
const recentAttempts = attempts.filter((time: number) => now - time < 60000);
if (recentAttempts.length >= 5) {
return false;
}
rateLimit.set(identifier, [...recentAttempts, now]);
return true;
}
Testing Your Authentication
Test your implementation:
-
Registration: POST to
/api/auth/registerwith email, password, name -
Login: POST to
/api/auth/loginwith credentials -
Access Protected Route: Visit
/dashboard(should redirect to login if not authenticated) -
Logout: POST to
/api/auth/logout
Common Pitfalls to Avoid
- Storing JWT in localStorage: Vulnerable to XSS attacks
- Weak JWT secrets: Use at least 32 random characters
- No token expiration: Tokens should expire
- Exposing sensitive data in tokens: Keep payloads minimal
- Not validating input: Always validate and sanitize user input
Advanced Features to Consider
- Refresh tokens for seamless re-authentication
- Email verification before allowing login
- Two-factor authentication for enhanced security
- Password reset functionality
- Social authentication (Google, GitHub, etc.)
- Role-based access control (RBAC)
Conclusion
JWT authentication in Next.js provides a robust, scalable solution for securing your applications. By following these patterns and security best practices, you can build production-ready authentication systems that protect user data while providing excellent user experience.
Remember to always keep security at the forefront, regularly update dependencies, and stay informed about the latest security vulnerabilities and best practices in web authentication.
Top comments (0)