
Implementing JWT Authentication with HTTP-Only Cookies in Next.js App Router
This article guides you through implementing a secure JWT-based authentication system in a Next.js application using the App Router, with HTTP-only cookies for enhanced security. We'll cover setting up the backend API, handling authentication, and securing routes.
Prerequisites
- Node.js (v18 or later)
- Next.js (v14 or later)
- Basic understanding of React and TypeScript
Step 1: Project Setup
First, create a new Next.js project with TypeScript:
npx create-next-app@latest my-auth-app --typescript --app
cd my-auth-app
Install required dependencies:
npm install jsonwebtoken bcryptjs cookie
Step 2: Setting Up Environment Variables
Create a .env.local file in the root directory to store sensitive information:
JWT_SECRET=your-secure-jwt-secret
Replace your-secure-jwt-secret with a strong, random string (at least 32 characters).
Step 3: Creating the Authentication API
Create an API route to handle login and token generation.
File: app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { serialize } from 'cookie';
// Mock user database (replace with actual database in production)
const users = [
{
id: 1,
email: 'user@example.com',
password: '$2a$10$...hashedPassword...', // Hash of "password123"
},
];
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
// Find user
const user = users.find((u) => u.email === email);
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Generate JWT
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET!,
{ expiresIn: '1h' }
);
// Set HTTP-only cookie
const cookie = serialize('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600, // 1 hour
path: '/',
});
const response = NextResponse.json({ message: 'Login successful' });
response.headers.set('Set-Cookie', cookie);
return response;
} catch (error) {
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
This API route:
- Accepts email and password in the request body.
- Validates credentials against a mock user database (replace with a real database in production).
- Generates a JWT with a 1-hour expiration.
- Sets an HTTP-only cookie with secure attributes.
Step 4: Middleware for Protected Routes
Create middleware to protect routes by verifying the JWT.
File: middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export async function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', req.url));
}
try {
jwt.verify(token, process.env.JWT_SECRET!);
return NextResponse.next();
} catch (error) {
return NextResponse.redirect(new URL('/login', req.url));
}
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};
This middleware:
- Checks for the JWT in the HTTP-only cookie.
- Verifies the token using the JWT secret.
- Redirects to the login page if the token is missing or invalid.
- Applies to routes under
/dashboardand/api/protected.
Step 5: Creating the Login Page
Create a login page for users to authenticate.
File: 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 router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
router.push('/dashboard');
} else {
const data = await res.json();
setError(data.error || 'Login failed');
}
} catch (err) {
setError('An error occurred');
}
};
return (
<div className="flex min-h-screen items-center justify-center">
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
<h1 className="text-2xl font-bold">Login</h1>
{error && <p className="text-red-500">{error}</p>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
className="border p-2"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="border p-2"
required
/>
<button type="submit" className="bg-blue-500 text-white p-2 rounded">
Login
</button>
</form>
</div>
);
}
This page:
- Provides a simple login form.
- Sends credentials to the
/api/auth/loginendpoint. - Redirects to the dashboard on successful login.
Step 6: Creating a Protected Dashboard
Create a protected dashboard page that requires authentication.
File: app/dashboard/page.tsx
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
export default function DashboardPage() {
const token = cookies().get('token')?.value;
let user = null;
if (token) {
try {
user = jwt.verify(token, process.env.JWT_SECRET!) as { email: string };
} catch (error) {
// Handle invalid token
}
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="p-4">
<h1 className="text-2xl font-bold">Dashboard</h1>
{user ? (
<p>Welcome, {user.email}!</p>
) : (
<p>Error: Unable to verify user</p>
)}
<a href="/api/auth/logout" className="text-blue-500">
Logout
</a>
</div>
</div>
);
}
Step 7: Implementing Logout
Create an API route to handle logout by clearing the cookie.
File: app/api/auth/logout/route.ts
import { NextResponse } from 'next/server';
import { serialize } from 'cookie';
export async function GET() {
const cookie = serialize('token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 0,
path: '/',
});
const response = NextResponse.json({ message: 'Logout successful' });
response.headers.set('Set-Cookie', cookie);
return response;
}
This route clears the token cookie, effectively logging the user out.
Step 8: Securing API Routes
Create a protected API route as an example.
File: app/api/protected/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number };
return NextResponse.json({ message: `Protected data for user ${decoded.userId}` });
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}
This route is protected by the middleware and only accessible with a valid JWT.
Step 9: Testing the Application
- Run the development server:
npm run dev
- Navigate to
http://localhost:3000/login. - Use the credentials
user@example.comandpassword123to log in. - Verify that you can access
/dashboardand/api/protected/data. - Test accessing protected routes without logging in (should redirect to
/login). - Test logout functionality via the
/api/auth/logoutendpoint.
Security Considerations
- Production Database: Replace the mock user database with a secure database like PostgreSQL or MongoDB.
- HTTPS: Always use HTTPS in production to ensure cookies are secure.
- Password Hashing: Ensure all passwords are hashed with bcrypt before storing.
- Token Expiry: Adjust the JWT expiry time based on your needs and implement refresh tokens for longer sessions.
- Rate Limiting: Add rate limiting to the login endpoint to prevent brute-force attacks.
- CSRF Protection: Consider adding CSRF tokens for POST requests in production.
Conclusion
This implementation provides a secure foundation for JWT-based authentication in Next.js using HTTP-only cookies. By leveraging the App Router and middleware, you can protect both pages and API routes while maintaining a smooth user experience. Extend this setup with refresh tokens, a proper database, and additional security measures for production use.