Authentication (verifying entity identity) and authorization (enforcing granular resource access) form the bedrock of corporate enterprise web security. In legacy client-side Single Page Application (SPA) architectures (such as React with Vite), session tokens were frequently stored in browser localStorage, and private route security was relegated to client-side lifecycle hooks within useEffect.
In the Next.js App Router architecture, this pattern is classified as a critical vulnerability. Because page rendering initiates on the server, client-side guards guarantee a data leak: the server will generate and transmit confidential server-rendered HTML across the public network first, only for the browser to execute client JavaScript and perform an unacceptably delayed redirect afterwards.
Modern architecture relies on a rigorous Defense in Depth strategy:
Application security is constructed as a multi-tiered defense perimeter:
Incoming Client HTTP Request
│
▼
[Tier 1: Edge Middleware] ──────────────────────► Reject: HTTP 307 Redirect (/login)
(Rapid cryptographic JWT-cookie verification)
│ Session valid
▼
[Tier 2: Server Layout / Page] ─────────────────► Reject: redirect('/unauthorized')
(Enforce role permissions: session.user.role)
│ Render protected UI
▼
[Tier 3: Server Action Guard] ──────────────────► Reject: throw new Error('Forbidden')
(Isolated session verification inside mutation)
│ Access granted
▼
Execute Database Mutation / TransactionThe user submits credentials or clicks a third-party OAuth provider button.
Auth.js generates PKCE challenges, state parameters, and redirects to the identity provider (Google/GitHub).
The provider redirects back to /api/auth/callback/[provider]. The server securely exchanges the auth code for tokens.
Auth.js bundles identity claims and user roles into an encrypted HttpOnly cookie with the __Secure- prefix.
The browser sends the cookie automatically on each request; Edge Middleware and RSC verify permissions.
A developer integrates authentication in Next.js 15 by creating a unified auth.ts file with PrismaAdapter and importing auth() into middleware.ts. During deployment to Vercel, the build fails with 'The edge runtime does not support Node.js net module'. What is the root cause and the correct architectural solution?
In strict TypeScript setups, manually casting session fields using as string introduces type degradation. Create a dedicated ambient type definition file:
import { type DefaultSession } from 'next-auth';
import { type JWT } from 'next-auth/jwt';
export type UserRole = 'USER' | 'ADMIN' | 'EDITOR';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: UserRole;
} & DefaultSession['user'];
}
interface User {
role?: UserRole;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string;
role?: UserRole;
}
}To service OAuth callback redirects and session verification endpoints, Auth.js provides dynamic HTTP handlers:
import { handlers } from '@/auth';
export const { GET, POST } = handlers;Middleware filters out unauthorized requests before any server rendering begins, ensuring zero CPU and memory load on backend services:
import NextAuth from 'next-auth';
import { authConfig } from '@/auth.config';
import { NextResponse } from 'next/server';
const { auth } = NextAuth(authConfig);
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const isDashboardRoute = nextUrl.pathname.startsWith('/dashboard');
const isAuthRoute = nextUrl.pathname.startsWith('/login') || nextUrl.pathname.startsWith('/register');
if (isAuthRoute && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', nextUrl));
}
if (isDashboardRoute && !isLoggedIn) {
let callbackUrl = nextUrl.pathname;
if (nextUrl.search) {
callbackUrl += nextUrl.search;
}
const encodedCallbackUrl = encodeURIComponent(callbackUrl);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${encodedCallbackUrl}`, nextUrl)
);
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};Inside Server Components, resolve the session asynchronously via direct invocation of auth():
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function AdminManagementPage() {
const session = await auth();
if (!session?.user) {
redirect('/login?callbackUrl=/admin');
}
if (session.user.role !== 'ADMIN') {
// Authenticated user lacks required elevated privileges
redirect('/unauthorized');
}
return (
<main className="p-8 max-w-6xl mx-auto">
<h1 className="text-3xl font-bold tracking-tight text-text-primary mb-4">
System Administration Console
</h1>
<p className="text-text-secondary">
Welcome, {session.user.name} ({session.user.email}). Access authorized.
</p>
</main>
);
}Every Server Action functions as an independently reachable network RPC endpoint. Middleware alone does not guarantee security for direct action invocations:
'use server';
import { auth } from '@/auth';
import { db } from '@/shared/lib/db';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const DeleteUserSchema = z.object({
userId: z.string().uuid(),
});
export type DeleteUserState =
| { status: 'idle' }
| { status: 'success'; message: string }
| { status: 'error'; message: string };
export async function deleteUserAction(
_prevState: DeleteUserState,
formData: FormData
): Promise<DeleteUserState> {
// 1. Mandatory identity verification of caller
const session = await auth();
if (!session?.user) {
return { status: 'error', message: 'Authentication required. Please log in.' };
}
// 2. Strict Role-Based Access Control (RBAC)
if (session.user.role !== 'ADMIN') {
return { status: 'error', message: 'Access denied: Administrator privileges required.' };
}
// 3. Schema validation
const parsed = DeleteUserSchema.safeParse({
userId: formData.get('userId'),
});
if (!parsed.success) {
return { status: 'error', message: 'Invalid target user identifier.' };
}
try {
await db.user.delete({
where: { id: parsed.data.userId },
});
revalidatePath('/admin/users');
return { status: 'success', message: 'User record deleted successfully.' };
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Database mutation error';
console.error('[DeleteUserAction Failed]:', errorMsg);
return { status: 'error', message: 'Failed to complete user deletion.' };
}
}For client-side interactive widgets (such as responsive profile dropdowns and avatar controls), leverage SessionProvider and the useSession() hook:
'use client';
import { useSession, signOut } from 'next-auth/react';
export function UserMenu() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <div className="h-9 w-9 rounded-full bg-bg-surface animate-pulse" />;
}
if (!session?.user) {
return (
<a href="/login" className="text-sm font-medium text-text-primary hover:text-accent-violet">
Sign In
</a>
);
}
return (
<div className="flex items-center gap-3">
<span className="text-sm text-text-secondary">{session.user.name}</span>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-bg-surface border border-border hover:bg-bg-subtle transition-colors"
>
Sign Out
</button>
</div>
);
}A developer builds a protected route at /admin/users with administrator role verification in middleware.ts. The page contains a user deletion form calling the deleteUserAction Server Action. Inside the action itself, no authorization checks are performed because 'users without admin rights cannot navigate to this page'. What critical security vulnerability exists in this implementation?
A pervasive mistake is assuming that because a route /admin is guarded in middleware.ts, all Server Actions called from within that page are protected automatically.
This is a critical security vulnerability. Next.js compiles every Server Action into an autonomous public HTTP POST endpoint designated by a unique Next-Action ID header. Any caller can fire a POST payload directly at the action using fetch or curl, completely bypassing the page and layout.
Solution: Every Server Action must independently execute const session = await auth() and verify permissions before executing mutations.
Next.js Middleware runs exclusively within the lightweight Edge Runtime, which does not support Node.js core modules (net, tls, child_process) or standard TCP database sockets. Importing a NextAuth instance bundled with PrismaAdapter directly into middleware.ts will trigger fatal build or runtime errors.
Solution: Split your auth configuration into auth.config.ts (Edge-compatible JWT checks) and auth.ts (Node.js runtime with PrismaAdapter).
When an access token expires, multiple parallel requests initiated by the same page can simultaneously trigger Refresh Token Rotation within the jwt callback. If the OAuth provider invalidates single-use refresh tokens immediately upon exchange, the second parallel request fails and logs the user out unexpectedly.
Solution: Implement a grace period (15–30 seconds) on your identity provider or serialize token exchanges via a Redis-backed distributed lock/mutex.
Enforcing page protection inside client-side useEffect hooks allows the server to render and transmit confidential HTML across the network during SSR before the browser JavaScript executes and calls router.push('/login'). Attackers inspecting raw network responses will view the private content.
Solution: Enforce authentication server-side in middleware.ts and root layout.tsx / page.tsx via await auth().