Skip to main content

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:

  1. It's stateless - no need to store session data on the server
  2. It scales well with serverless functions
  3. It works across different domains
  4. 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:

bash
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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

jsx
// 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:

jsx
// 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:

jsx
// 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:

javascript
// 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:

jsx
// 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:

javascript
// 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:

javascript
// 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:

  1. Use HTTPS: Always use HTTPS in production to protect tokens in transit.

  2. Store secrets securely: Store your JWT secret key in environment variables, not in your code.

  3. Set appropriate expiration times: Short-lived tokens are more secure. Implement refresh tokens for longer sessions.

  4. Use httpOnly cookies: This prevents client-side JavaScript from accessing tokens, which helps mitigate XSS attacks.

  5. Implement CSRF protection: Cross-Site Request Forgery attacks can be mitigated with CSRF tokens.

  6. Validate user input: Always validate and sanitize user input to prevent injection attacks.

  7. 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:

  1. We set up JWT generation and verification utilities
  2. Created API routes for user registration, login, and logout
  3. Built a context provider to manage authentication state
  4. Created UI components for login and registration
  5. Implemented protected routes with a Higher Order Component
  6. 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

Exercises

  1. Add Email Verification: Extend the registration process to include email verification before activating user accounts.

  2. Implement Refresh Tokens: Modify the authentication system to use short-lived access tokens and longer-lived refresh tokens.

  3. Role-based Access Control: Add user roles (e.g., admin, user) and create routes that are accessible based on user roles.

  4. Password Reset Flow: Implement a forgotten password flow that allows users to reset their passwords via email.

  5. 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! :)