Architecture Patterns for Secure Session & Token Management in Next.js 15 with React & TypeScript (2026)
As web applications grow in complexity and security threats evolve, robust authentication and session management become paramount. Next.js 15, with its advancements in Middleware, Server Components, and Server Actions, offers powerful primitives to build highly secure and performant authentication systems. This post outlines an architecture pattern for secure session and token management using JWTs stored in HTTP-only cookies, leveraging Next.js Middleware for global authentication checks and Server Actions for authenticated data operations, all typed with TypeScript.
This pattern prioritizes server-side security, minimizing client-side exposure of sensitive tokens while maintaining a smooth user experience.
1. The Foundation: HTTP-only JWTs & Security Principles
Our secure authentication strategy relies on JSON Web Tokens (JWTs) for stateless session management, delivered and managed primarily on the server-side. Key security principles include:
- HTTP-only Cookies: Access and Refresh Tokens are stored in HTTP-only cookies, preventing client-side JavaScript from accessing them and mitigating XSS (Cross-Site Scripting) attacks.
- SameSite=Lax/Strict: Protects against CSRF (Cross-Site Request Forgery) attacks.
- Secure Flag: Ensures cookies are only sent over HTTPS connections.
- Token Rotation: Regularly refreshing access tokens with a valid refresh token enhances security by limiting the lifespan of a single access token.
- Server-Side Validation: All token validation happens on the server, within Next.js Middleware and Server Actions.
Authentication API Route (Example)
When a user logs in, the server generates an Access Token (short-lived) and a Refresh Token (longer-lived). Both are set as HTTP-only cookies.
// app/api/auth/login/route.ts
// This is a simplified example. In a real app, you'd validate user credentials
// against a database, hash passwords, etc.
import { NextRequest, NextResponse } from 'next/server';
import { SignJWT } from 'jose';
import { getSecretKey } from '@/utils/auth-config';
interface UserSession {
id: string;
email: string;
roles: string[];
}
const JWT_ACCESS_EXPIRATION = '15m'; // 15 minutes
const JWT_REFRESH_EXPIRATION = '7d'; // 7 days
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// 1. Authenticate user (e.g., check credentials against database)
// For demonstration:
if (email !== 'user@example.com' || password !== 'password123') {
return NextResponse.json({ message: 'Invalid credentials' }, { status: 401 });
}
const user: UserSession = { id: 'user_123', email: 'user@example.com', roles: ['user'] };
// 2. Generate Access Token
const accessToken = await new SignJWT({ userId: user.id, email: user.email, roles: user.roles })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(JWT_ACCESS_EXPIRATION)
.sign(getSecretKey());
// 3. Generate Refresh Token
const refreshToken = await new SignJWT({ userId: user.id }) // Refresh token usually contains minimal info
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(JWT_REFRESH_EXPIRATION)
.sign(getSecretKey('REFRESH_SECRET')); // Use a different secret for refresh tokens
// 4. Set HTTP-only cookies
const response = NextResponse.json({ message: 'Login successful', user }, { status: 200 });
response.cookies.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 15, // 15 minutes
path: '/',
});
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
}
// utils/auth-config.ts
import { SignJWT, jwtVerify } from 'jose';
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'super_secret_access_key';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'super_secret_refresh_key';
export function getSecretKey(type: 'ACCESS_SECRET' | 'REFRESH_SECRET' = 'ACCESS_SECRET'): Uint8Array {
const secret = type === 'ACCESS_SECRET' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
return new TextEncoder().encode(secret);
}
export interface AuthenticatedUserPayload {
userId: string;
email: string;
roles: string[];
}
export async function verifyToken(token: string, type: 'ACCESS_SECRET' | 'REFRESH_SECRET' = 'ACCESS_SECRET') {
try {
const { payload } = await jwtVerify(token, getSecretKey(type), {
algorithms: ['HS256'],
});
return payload as AuthenticatedUserPayload;
} catch (error) {
console.error('Token verification failed:', error);
return null;
}
}
export async function refreshAccessToken(refreshToken: string) {
try {
const refreshPayload = await verifyToken(refreshToken, 'REFRESH_SECRET');
if (!refreshPayload || !refreshPayload.userId) {
return null;
}
// In a real application, you'd likely check if the refresh token is still valid
// against a database (e.g., if it was revoked or used).
// For this example, we assume if it verifies, it's valid.
// Generate a new access token
const newAccessToken = await new SignJWT({ userId: refreshPayload.userId, email: refreshPayload.email, roles: refreshPayload.roles })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(getSecretKey('ACCESS_SECRET'));
return newAccessToken;
} catch (error) {
console.error('Refresh token failed:', error);
return null;
}
}
2. Next.js Middleware for Global Authentication & Token Refresh
Next.js Middleware is ideal for protecting routes and handling global authentication concerns like token validation and silent refreshing. It executes before a request is completed, allowing us to modify responses, rewrite URLs, or redirect users.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken, refreshAccessToken } from '@/utils/auth-config';
// Define public paths that don't require authentication
const publicPaths = ['/api/auth/login', '/login', '/signup', '/'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if the path is public
if (publicPaths.includes(pathname)) {
return NextResponse.next();
}
const accessToken = request.cookies.get('access_token')?.value;
const refreshToken = request.cookies.get('refresh_token')?.value;
let response = NextResponse.next();
let isAuthenticated = false;
let userPayload = null;
if (accessToken) {
userPayload = await verifyToken(accessToken, 'ACCESS_SECRET');
if (userPayload) {
isAuthenticated = true;
}
}
// If access token is expired/invalid but a refresh token exists, attempt refresh
if (!isAuthenticated && accessToken && refreshToken) {
const newAccessToken = await refreshAccessToken(refreshToken);
if (newAccessToken) {
// Set the new access token in the response cookies
response.cookies.set('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 15, // 15 minutes
path: '/',
});
// Re-verify with the new token
userPayload = await verifyToken(newAccessToken, 'ACCESS_SECRET');
if (userPayload) {
isAuthenticated = true;
}
}
}
// If still not authenticated, redirect to login
if (!isAuthenticated) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname); // Optional: redirect back after login
// Clear potentially invalid tokens to prevent infinite loops or stale tokens
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return NextResponse.redirect(loginUrl);
}
// Optionally, you can pass user info to subsequent server components/actions via headers
// Be cautious with sensitive data in headers. For simple IDs, it might be acceptable.
// For full user objects, rely on Server Actions fetching from secure contexts.
if (userPayload) {
response.headers.set('X-User-Id', userPayload.userId);
// You might also set other headers like roles if needed
}
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // Apply middleware to all paths except static assets
};
3. Server Actions for Authenticated Data Operations
Server Actions provide a secure, direct way to interact with your server-side logic from React components. For authenticated actions, we retrieve the token directly from cookies within the action and validate it, ensuring each operation is explicitly authorized.
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
import { verifyToken, AuthenticatedUserPayload } from '@/utils/auth-config';
// Define a type for the context if needed for actions
interface ActionContext {
user: AuthenticatedUserPayload;
}
/**
* Helper to get and verify the access token from cookies for a Server Action.
* Throws an error if the token is missing or invalid.
*/
async function getAuthenticatedUser(): Promise<AuthenticatedUserPayload> {
const cookieStore = cookies();
const accessToken = cookieStore.get('access_token')?.value;
if (!accessToken) {
throw new Error('Authentication required: No access token found.');
}
const user = await verifyToken(accessToken, 'ACCESS_SECRET');
if (!user) {
throw new Error('Authentication required: Invalid or expired access token.');
}
return user;
}
export async function getUserProfile() {
try {
const user = await getAuthenticatedUser();
// Simulate fetching user profile from a database or external API
console.log(`Fetching profile for user: ${user.userId}`);
return {
id: user.userId,
email: user.email,
roles: user.roles,
preferences: { theme: 'dark', notifications: true },
};
} catch (error) {
console.error('Error fetching user profile:', error);
// Re-throw or return a specific error for client-side handling
throw new Error('Failed to fetch user profile: ' + (error as Error).message);
}
}
interface UpdateProfileData {
theme?: 'light' | 'dark';
notifications?: boolean;
}
export async function updateUserProfile(data: UpdateProfileData) {
try {
const user = await getAuthenticatedUser();
// Simulate updating user profile in a database
console.log(`Updating profile for user: ${user.userId} with data:`, data);
// ... database update logic ...
return { success: true, message: 'Profile updated successfully.' };
} catch (error) {
console.error('Error updating user profile:', error);
throw new Error('Failed to update user profile: ' + (error as Error).message);
}
}
export async function logoutUser() {
const cookieStore = cookies();
cookieStore.delete('access_token');
cookieStore.delete('refresh_token');
// Invalidate refresh token on the server-side in a real application
return { success: true, message: 'Logged out successfully.' };
}
Using Server Actions in a React Component
On the client-side, React components can invoke these Server Actions directly and securely.
// app/dashboard/page.tsx
'use client'; // This component uses client-side interactivity, though it can be an RSC that uses actions.
import React, { useEffect, useState } from 'react';
import { getUserProfile, updateUserProfile, logoutUser } from '@/app/actions';
import { useRouter } from 'next/navigation';
interface UserProfile {
id: string;
email: string;
roles: string[];
preferences: { theme: 'light' | 'dark'; notifications: boolean };
}
export default function DashboardPage() {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
const fetchProfile = async () => {
try {
const userProfile = await getUserProfile();
setProfile(userProfile);
} catch (err) {
setError((err as Error).message);
// If auth fails, redirect to login
router.push('/login?redirect=/dashboard');
} finally {
setLoading(false);
}
};
fetchProfile();
}, [router]);
const handleUpdateTheme = async (theme: 'light' | 'dark') => {
try {
await updateUserProfile({ theme });
setProfile(prev => prev ? { ...prev, preferences: { ...prev.preferences, theme } } : null);
} catch (err) {
setError((err as Error).message);
}
};
const handleLogout = async () => {
await logoutUser();
router.push('/login');
};
if (loading) return <p>Loading profile...</p>;
if (error) return <p className="text-red-500">Error: {error}</p>;
if (!profile) return <p>No profile data available. Please log in.</p>;
return (
<div className="container mx-auto p-4 max-w-md">
<h1 className="text-2xl font-bold mb-4">Welcome, {profile.email}</h1>
<p className="mb-2">Your User ID: {profile.id}</p>
<p className="mb-4">Roles: {profile.roles.join(', ')}</p>
<div className="mb-4">
<h2 className="text-xl font-semibold mb-2">Preferences</h2>
<p>Theme: {profile.preferences.theme}</p>
<button
className="bg-blue-500 text-white px-4 py-2 rounded mr-2"
onClick={() => handleUpdateTheme(profile.preferences.theme === 'dark' ? 'light' : 'dark')}
>
Toggle Theme
</button>
</div>
<button
className="bg-red-500 text-white px-4 py-2 rounded"
onClick={handleLogout}
>
Logout
</button>
</div>
);
}
4. Types for Enhanced Developer Experience & Safety
Utilizing TypeScript interfaces for JWT payloads and user sessions ensures type safety and clarity across your application.
// types/auth.d.ts
// Re-exporting from auth-config for consistency, or define separately if preferred
import { AuthenticatedUserPayload as BaseAuthenticatedUserPayload } from '@/utils/auth-config';
declare global {
// Extend NextRequest if you needed to pass user data directly, though Server Actions
// often prefer explicit cookie access for better isolation.
// declare namespace Next {
// interface NextRequest {
// user?: AuthenticatedUserPayload;
// }
// }
}
export interface UserSession extends BaseAuthenticatedUserPayload {
// Add any other client-side specific user data here
// For instance, a profile picture URL, display name, etc.
// that might be derived from the base payload but not necessarily part of the JWT.
}
// Example usage in React component types (if not already defined)
interface UserProfile {
id: string;
email: string;
roles: string[];
preferences: { theme: 'light' | 'dark'; notifications: boolean };
}
This architectural pattern provides a robust and secure foundation for session and token management in Next.js 15. By leveraging HTTP-only cookies, Next.js Middleware for global checks and token refreshing, and Server Actions for authenticated data operations, you minimize client-side attack vectors and centralize authentication logic.
Remember to always keep your secret keys secure, consider database-backed refresh token revocation for enhanced security, and adapt token expiration strategies based on your application's specific security requirements.
📚 More Resources
Check out related content:
Looking for beautiful UI layouts and CSS animations?
🎨 Need Design? Get Pure CSS Inspiration →
Comments
Post a Comment