Next.js JWT Authentication
Introduction
Authentication is a critical aspect of modern web applications, allowing you to secure resources and personalize user experiences. JSON Web Tokens (JWT) provide a stateless, efficient way to handle authentication in Next.js applications.
In this guide, you'll learn how to implement JWT authentication in a Next.js application. We'll cover everything from the basic concepts to creating a complete authentication system with protected routes.
What is JWT?
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way to securely transmit information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
A JWT consists of three parts:
- Header: Contains the type of token and the signing algorithm
- Payload: Contains the claims (user information and additional data)
- Signature: Verifies the token hasn't been altered
A typical JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Why Use JWT in Next.js?
Next.js is a React framework that supports both server-side rendering and static site generation. JWT authentication works well with Next.js because:
- It's stateless - no need to store session data on the server
- It scales well with serverless functions
- It works across different domains
- It integrates easily with Next.js API routes
Setting Up JWT Authentication in Next.js
Let's build a complete JWT authentication system step by step.
Step 1: Install Required Dependencies
First, we need to install the necessary packages:
npm install jsonwebtoken cookie js-cookie bcryptjs
# or
yarn add jsonwebtoken cookie js-cookie bcryptjs
Step 2: Create Authentication Utilities
Create a new folder called lib
in your project root and add an auth.js
file:
// lib/auth.js
import jwt from 'jsonwebtoken';
import { serialize } from 'cookie';
const SECRET_KEY = process.env.JWT_SECRET || 'your_secret_key_here';
const TOKEN_NAME = 'authToken';
// Create a JWT token
export function createToken(payload) {
return jwt.sign(payload, SECRET_KEY, {
expiresIn: '1d', // Token expires in 1 day
});
}
// Verify a JWT token
export function verifyToken(token) {
try {
return jwt.verify(token, SECRET_KEY);
} catch (error) {
return null;
}
}
// Create a cookie with the JWT token
export function createTokenCookie(token) {
return serialize(TOKEN_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 86400, // 1 day in seconds
path: '/',
});
}
// Remove the auth cookie (for logout)
export function removeTokenCookie() {
return serialize(TOKEN_NAME, '', {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: -1,
path: '/',
});
}
Step 3: Set Up User Authentication API
Create API routes for user registration, login, and logout:
// pages/api/auth/register.js
import bcrypt from 'bcryptjs';
import { createToken, createTokenCookie } from '../../../lib/auth';
// In a real application, you would use a database
// This is just a simple example
const users = [];
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const { username, password } = req.body;
// Check if user already exists
if (users.find(user => user.username === username)) {
return res.status(400).json({ message: 'User already exists' });
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create a new user
const newUser = {
id: Date.now().toString(),
username,
password: hashedPassword,
};
users.push(newUser);
// Create a JWT token
const token = createToken({ id: newUser.id, username });
// Set the cookie
res.setHeader('Set-Cookie', createTokenCookie(token));
// Return user info (without password)
const { password: _, ...userWithoutPassword } = newUser;
res.status(201).json({ user: userWithoutPassword });
} catch (error) {
res.status(500).json({ message: 'Error registering user', error: error.message });
}
}
Now, let's create the login endpoint:
// pages/api/auth/login.js
import bcrypt from 'bcryptjs';
import { createToken, createTokenCookie } from '../../../lib/auth';
// Using the same users array for demonstration
// In a real app, you'd query your database
const users = [];
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const { username, password } = req.body;
// Find the user
const user = users.find(user => user.username === username);
// Check if user exists and password is correct
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ message: 'Invalid username or password' });
}
// Create a JWT token
const token = createToken({ id: user.id, username });
// Set the cookie
res.setHeader('Set-Cookie', createTokenCookie(token));
// Return user info (without password)
const { password: _, ...userWithoutPassword } = user;
res.status(200).json({ user: userWithoutPassword });
} catch (error) {
res.status(500).json({ message: 'Error logging in', error: error.message });
}
}
And finally, let's create a logout endpoint:
// pages/api/auth/logout.js
import { removeTokenCookie } from '../../../lib/auth';
export default function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
// Remove the auth cookie
res.setHeader('Set-Cookie', removeTokenCookie());
res.status(200).json({ message: 'Logged out successfully' });
}
Step 4: Create a User Context
To manage authentication state throughout your application, create an auth context:
// contexts/AuthContext.js
import { createContext, useState, useContext, useEffect } from 'react';
import { useRouter } from 'next/router';
import Cookies from 'js-cookie';
const AuthContext = createContext({});
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
// Check if there's a user on page load
async function loadUserFromCookies() {
const token = Cookies.get('authToken');
if (token) {
try {
// Fetch the user data
const res = await fetch('/api/auth/me');
if (res.ok) {
const userData = await res.json();
setUser(userData.user);
} else {
Cookies.remove('authToken');
setUser(null);
}
} catch (error) {
console.error('Error loading user data:', error);
}
}
setLoading(false);
}
loadUserFromCookies();
}, []);
const login = async (username, password) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
return true;
}
return false;
};
const register = async (username, password) => {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
return true;
}
return false;
};
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
Cookies.remove('authToken');
setUser(null);
router.push('/login');
};
return (
<AuthContext.Provider value={{ isAuthenticated: !!user, user, login, register, logout, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
Step 5: Create an API route to get the current user
This API route will allow us to get the currently authenticated user:
// pages/api/auth/me.js
import { verifyToken } from '../../../lib/auth';
export default function handler(req, res) {
// Get the token from the cookies
const token = req.cookies.authToken;
if (!token) {
return res.status(401).json({ message: 'Not authenticated' });
}
// Verify the token
const userData = verifyToken(token);
if (!userData) {
return res.status(401).json({ message: 'Invalid token' });
}
// In a real app, you would fetch the complete user data from your database
// using the user ID from the token
res.status(200).json({ user: { id: userData.id, username: userData.username } });
}
Step 6: Use the Auth Context in your app
Update your _app.js
file to include the AuthProvider:
// pages/_app.js
import { AuthProvider } from '../contexts/AuthContext';
import '../styles/globals.css';
function MyApp({ Component, pageProps }) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
export default MyApp;
Step 7: Create Authentication UI Components
Let's create login and register forms:
// pages/login.js
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useRouter } from 'next/router';
import Link from 'next/link';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (success) {
router.push('/dashboard');
} else {
setError('Invalid username or password');
}
};
return (
<div className="container">
<h1>Login</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit">Login</button>
</form>
<p>
Don't have an account? <Link href="/register">Register</Link>
</p>
</div>
);
}
And the register page:
// pages/register.js
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useRouter } from 'next/router';
import Link from 'next/link';
export default function Register() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { register } = useAuth();
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const success = await register(username, password);
if (success) {
router.push('/dashboard');
} else {
setError('Registration failed');
}
};
return (
<div className="container">
<h1>Register</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit">Register</button>
</form>
<p>
Already have an account? <Link href="/login">Login</Link>
</p>
</div>
);
}
Step 8: Create Protected Routes
Let's create a Higher Order Component (HOC) to protect routes:
// components/withAuth.js
import { useRouter } from 'next/router';
import { useAuth } from '../contexts/AuthContext';
export function withAuth(Component) {
return function AuthenticatedComponent(props) {
const { isAuthenticated, loading } = useAuth();
const router = useRouter();
// If auth is still loading, show nothing or a loading spinner
if (loading) {
return <div>Loading...</div>;
}
// If user is not authenticated, redirect to login
if (!isAuthenticated) {
router.replace('/login');
return null;
}
// If user is authenticated, render the component
return <Component {...props} />;
};
}
Now let's use it to protect a dashboard page:
// pages/dashboard.js
import { withAuth } from '../components/withAuth';
import { useAuth } from '../contexts/AuthContext';
function Dashboard() {
const { user, logout } = useAuth();
return (
<div className="container">
<h1>Dashboard</h1>
<p>Welcome, {user?.username}!</p>
<button onClick={logout}>Logout</button>
<div className="content">
<h2>Protected Content</h2>
<p>This content is only visible to authenticated users.</p>
</div>
</div>
);
}
export default withAuth(Dashboard);
Making API Requests with JWT Authentication
When you need to make API requests to protected endpoints from the client side, you can use the JWT token stored in cookies:
// Example of making an authenticated API request
async function fetchProtectedData() {
try {
const res = await fetch('/api/protected-resource', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// The cookie will be sent automatically
});
if (!res.ok) {
throw new Error('Failed to fetch data');
}
const data = await res.json();
return data;
} catch (error) {
console.error('Error fetching protected data:', error);
return null;
}
}
And on the server side, you can protect your API routes:
// pages/api/protected-resource.js
import { verifyToken } from '../../lib/auth';
export default function handler(req, res) {
// Get the token from the cookies
const token = req.cookies.authToken;
if (!token) {
return res.status(401).json({ message: 'Not authenticated' });
}
// Verify the token
const userData = verifyToken(token);
if (!userData) {
return res.status(401).json({ message: 'Invalid token' });
}
// Process the authenticated request
res.status(200).json({
message: 'This is protected data',
user: userData.username,
data: {
secretInfo: 'Only authenticated users can see this',
}
});
}
Security Considerations
When implementing JWT authentication, keep these security best practices in mind:
-
Use HTTPS: Always use HTTPS in production to protect tokens in transit.
-
Store secrets securely: Store your JWT secret key in environment variables, not in your code.
-
Set appropriate expiration times: Short-lived tokens are more secure. Implement refresh tokens for longer sessions.
-
Use httpOnly cookies: This prevents client-side JavaScript from accessing tokens, which helps mitigate XSS attacks.
-
Implement CSRF protection: Cross-Site Request Forgery attacks can be mitigated with CSRF tokens.
-
Validate user input: Always validate and sanitize user input to prevent injection attacks.
-
Limit JWT payload size: Keep your JWT payloads small to optimize performance.
Summary
In this guide, we've built a complete JWT authentication system for a Next.js application:
- We set up JWT generation and verification utilities
- Created API routes for user registration, login, and logout
- Built a context provider to manage authentication state
- Created UI components for login and registration
- Implemented protected routes with a Higher Order Component
- Added secure API endpoints that require authentication
JWT authentication provides a secure and scalable way to handle user authentication in Next.js applications. It's particularly well-suited for modern applications with separate frontend and backend components or those built with serverless architectures.
Additional Resources
- JSON Web Tokens (JWT) Official Documentation
- Next.js Authentication Documentation
- OWASP Authentication Cheat Sheet
- HTTP Cookies MDN Documentation
Exercises
-
Add Email Verification: Extend the registration process to include email verification before activating user accounts.
-
Implement Refresh Tokens: Modify the authentication system to use short-lived access tokens and longer-lived refresh tokens.
-
Role-based Access Control: Add user roles (e.g., admin, user) and create routes that are accessible based on user roles.
-
Password Reset Flow: Implement a forgotten password flow that allows users to reset their passwords via email.
-
Two-Factor Authentication: Add an optional two-factor authentication system using time-based one-time passwords (TOTP).
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)