JWT Authentication Flow Overview
- User Login: User submits credentials (email/password)
- Server Validation: Server verifies credentials against database
- Token Generation: Server creates access token (short-lived, 15min) and refresh token (long-lived, 7 days)
- Token Storage: Access token in memory/state, refresh token in httpOnly cookie
- API Requests: Client sends access token in Authorization header
- Token Verification: Server validates token on every protected route
- Token Refresh: When access token expires, use refresh token to get new one
- Logout: Clear tokens and invalidate refresh token on server
Step 1
Backend Setup - Token Generation
Install JWT library and create utilities to generate, verify, and decode tokens.
# Install dependencies
npm install jsonwebtoken
npm install -D @types/jsonwebtoken
# Install bcrypt for password hashing
npm install bcryptjs
npm install -D @types/bcryptjs
Environment Variables (.env.local):
# JWT Secrets (generate with: openssl rand -base64 32)
JWT_ACCESS_SECRET=your-super-secret-access-key-min-32-chars
JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-chars
# Token expiration
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
// lib/jwt.ts
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
export interface TokenPayload {
userId: string;
email: string;
role?: string;
}
// Generate access token (short-lived, 15 minutes)
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: process.env.JWT_ACCESS_EXPIRY || "15m",
});
}
// Generate refresh token (long-lived, 7 days)
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, REFRESH_SECRET, {
expiresIn: process.env.JWT_REFRESH_EXPIRY || "7d",
});
}
// Verify access token
export function verifyAccessToken(token: string): TokenPayload {
try {
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
} catch (error) {
throw new Error("Invalid or expired access token");
}
}
// Verify refresh token
export function verifyRefreshToken(token: string): TokenPayload {
try {
return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
} catch (error) {
throw new Error("Invalid or expired refresh token");
}
}
// Decode token without verification (for debugging)
export function decodeToken(token: string): TokenPayload | null {
try {
return jwt.decode(token) as TokenPayload;
} catch {
return null;
}
}
💡 Key Point: Use different secrets for access and refresh tokens. Access tokens are short-lived (15min) for security. Refresh tokens are long-lived (7 days) for convenience.
Step 2
Login API Route
Create login endpoint that validates credentials, generates tokens, and sets httpOnly cookie for refresh token.
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { generateAccessToken, generateRefreshToken } from "@/lib/jwt";
// Mock user lookup - replace with your database
async function findUserByEmail(email: string) {
// Example: await prisma.user.findUnique({ where: { email } })
return {
id: "user-123",
email: "user@example.com",
passwordHash: "$2a$10$...", // bcrypt hashed password
role: "user",
};
}
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Validation
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password required" },
{ status: 400 }
);
}
// 1. Find user in database
const user = await findUserByEmail(email);
if (!user) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
// 2. Verify password
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
// 3. Generate tokens
const payload = {
userId: user.id,
email: user.email,
role: user.role,
};
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// 4. Store refresh token in database (for revocation)
// await prisma.refreshToken.create({
// data: { token: refreshToken, userId: user.id, expiresAt: ... }
// });
// 5. Set refresh token as httpOnly cookie
const response = NextResponse.json({
success: true,
accessToken,
user: {
id: user.id,
email: user.email,
role: user.role,
},
});
response.cookies.set("refreshToken", refreshToken, {
httpOnly: true, // Cannot be accessed by JavaScript
secure: process.env.NODE_ENV === "production", // HTTPS only in production
sameSite: "strict", // CSRF protection
maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
path: "/",
});
return response;
} catch (error) {
console.error("Login error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
💡 Key Point: Never return the same message for "user not found" vs "wrong password" - prevents user enumeration attacks. Always hash passwords with bcrypt before comparing.
Step 3
Token Refresh Route
When access token expires, use refresh token to get a new one without re-login.
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyRefreshToken, generateAccessToken } from "@/lib/jwt";
export async function POST(request: NextRequest) {
try {
// 1. Get refresh token from cookie
const refreshToken = request.cookies.get("refreshToken")?.value;
if (!refreshToken) {
return NextResponse.json(
{ error: "No refresh token provided" },
{ status: 401 }
);
}
// 2. Verify refresh token
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch (error) {
return NextResponse.json(
{ error: "Invalid or expired refresh token" },
{ status: 401 }
);
}
// 3. Check if refresh token is still valid in database
// const storedToken = await prisma.refreshToken.findUnique({
// where: { token: refreshToken },
// });
//
// if (!storedToken || storedToken.expiresAt < new Date()) {
// return NextResponse.json(
// { error: "Refresh token revoked or expired" },
// { status: 401 }
// );
// }
// 4. Generate new access token
const newAccessToken = generateAccessToken({
userId: payload.userId,
email: payload.email,
role: payload.role,
});
// 5. Return new access token
return NextResponse.json({
success: true,
accessToken: newAccessToken,
});
} catch (error) {
console.error("Refresh error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
💡 Key Point: Store refresh tokens in the database so you can revoke them when needed (logout, security breach, etc.). Refresh tokens should be single-use in high-security apps.
Step 4
Protected Routes & Middleware
Protect API routes by verifying JWT tokens. Create middleware to authenticate requests.
// middleware.ts (root of project)
import { NextRequest, NextResponse } from "next/server";
import { verifyAccessToken } from "@/lib/jwt";
export function middleware(request: NextRequest) {
// Get token from Authorization header
const authHeader = request.headers.get("authorization");
const token = authHeader?.replace("Bearer ", "");
if (!token) {
return NextResponse.json(
{ error: "Unauthorized - No token provided" },
{ status: 401 }
);
}
try {
// Verify token
const payload = verifyAccessToken(token);
// Attach user info to headers (accessible in route handlers)
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.userId);
requestHeaders.set("x-user-email", payload.email);
requestHeaders.set("x-user-role", payload.role || "user");
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
} catch (error) {
return NextResponse.json(
{ error: "Unauthorized - Invalid token" },
{ status: 401 }
);
}
}
// Apply middleware only to protected routes
export const config = {
matcher: [
"/api/users/:path*",
"/api/dashboard/:path*",
"/api/admin/:path*",
// Don't protect auth routes
"/((?!api/auth).*)",
],
};
Alternative: Per-Route Protection
// lib/auth-middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyAccessToken } from "./jwt";
export function withAuth(
handler: (req: NextRequest, userId: string) => Promise<NextResponse>
) {
return async (req: NextRequest) => {
const authHeader = req.headers.get("authorization");
const token = authHeader?.replace("Bearer ", "");
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = verifyAccessToken(token);
return handler(req, payload.userId);
} catch (error) {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
};
}
// Usage in route
// app/api/profile/route.ts
import { withAuth } from "@/lib/auth-middleware";
export const GET = withAuth(async (req, userId) => {
const user = await getUserById(userId);
return NextResponse.json({ user });
});
💡 Key Point: Use Next.js middleware for global protection. Use per-route wrappers for fine-grained control. Always validate tokens on the server, never trust client-side logic.
Step 5
Client-Side Auth Context
Create an Auth context to manage authentication state, login, logout, and auto-refresh tokens.
// contexts/AuthContext.tsx
"use client";
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import axios from "axios";
interface User {
id: string;
email: string;
role: string;
}
interface AuthContextType {
user: User | null;
accessToken: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAuthenticated: boolean;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Login function
const login = async (email: string, password: string) => {
const response = await axios.post("/api/auth/login", { email, password });
const { accessToken, user } = response.data;
setAccessToken(accessToken);
setUser(user);
};
// Logout function
const logout = async () => {
try {
await axios.post("/api/auth/logout");
} finally {
setAccessToken(null);
setUser(null);
}
};
// Refresh token automatically
const refreshAccessToken = async () => {
try {
const response = await axios.post("/api/auth/refresh");
const { accessToken } = response.data;
setAccessToken(accessToken);
// Decode token to get user info
const payload = JSON.parse(atob(accessToken.split(".")[1]));
setUser({
id: payload.userId,
email: payload.email,
role: payload.role,
});
} catch (error) {
console.error("Token refresh failed:", error);
setAccessToken(null);
setUser(null);
}
};
// Try to refresh token on mount
useEffect(() => {
refreshAccessToken().finally(() => setIsLoading(false));
}, []);
// Auto-refresh token before expiry (every 10 minutes)
useEffect(() => {
if (!accessToken) return;
const interval = setInterval(() => {
refreshAccessToken();
}, 10 * 60 * 1000); // 10 minutes
return () => clearInterval(interval);
}, [accessToken]);
return (
<AuthContext.Provider
value={{
user,
accessToken,
login,
logout,
isAuthenticated: !!user,
isLoading,
}}
>
{children}
</AuthContext.Provider>
);
}
// Custom hook to use auth
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
Login Component:
"use client";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const { login, isLoading } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await login(email, password);
router.push("/dashboard");
} catch (err: any) {
setError(err.response?.data?.error || "Login failed");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
{error && <div style={{ color: "red" }}>{error}</div>}
<button type="submit" disabled={isLoading}>
Login
</button>
</form>
);
}
Protected Page Component:
"use client";
import { useAuth } from "@/contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function DashboardPage() {
const { user, isAuthenticated, isLoading, logout } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return null;
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user?.email}!</p>
<p>Role: {user?.role}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
Step 6
Axios Integration with Auto-Refresh
Configure axios to automatically add auth tokens and refresh them when they expire.
// lib/axiosAuth.ts
import axios from "axios";
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
const axiosAuth = axios.create({
baseURL: "/api",
});
// Request interceptor - add token
axiosAuth.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor - handle 401 and refresh
axiosAuth.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and haven't retried yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Refresh token
const response = await axios.post("/api/auth/refresh");
const { accessToken: newToken } = response.data;
// Update token
setAccessToken(newToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axiosAuth(originalRequest);
} catch (refreshError) {
// Refresh failed - redirect to login
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default axiosAuth;
Update Auth Context to use axiosAuth:
// In AuthContext.tsx
import axiosAuth, { setAccessToken } from "@/lib/axiosAuth";
// In login function:
const { accessToken, user } = response.data;
setAccessToken(accessToken); // Update axios instance
setAccessToken(accessToken); // Also set in state
setUser(user);
// Now all API calls use authenticated axios:
const users = await axiosAuth.get("/users"); // Token auto-added!
Best Practices
Security Best Practices
- Short-lived access tokens: 15 minutes max. Forces frequent validation.
- Store refresh tokens in httpOnly cookies: Prevents XSS attacks.
- Use HTTPS in production: Protect tokens in transit.
- Implement CSRF protection: Use sameSite: "strict" on cookies.
- Store refresh tokens in database: Enable revocation on logout/compromise.
- Hash passwords with bcrypt: Never store plain text passwords.
- Rate limit login attempts: Prevent brute force attacks.
- Use environment variables: Never hardcode secrets.
- Implement logout on all devices: Invalidate all refresh tokens.
- Add token rotation: Issue new refresh token on each refresh (advanced).
- Monitor suspicious activity: Log auth failures, unusual patterns.
- Never expose sensitive data in JWTs: Tokens can be decoded by anyone.