La autenticación (verificación de la identidad del sujeto) y la autorización (delimitación granular de permisos sobre los recursos) constituyen el pilar fundamental de la seguridad en aplicaciones web empresariales. En arquitecturas SPA tradicionales del lado del cliente (como React con Vite), los tokens de sesión se guardaban habitualmente en localStorage, y la protección de vistas privadas se delegaba a hooks del ciclo de vida del cliente dentro de useEffect.
En la arquitectura de Next.js App Router, este patrón se cataloga como una vulnerabilidad crítica. Dado que el renderizado de la página se origina en el servidor, las verificaciones en el cliente provocan una fuga inevitable: el servidor generará y transmitirá por la red el marcado HTML confidencial primero, para que solo después el navegador ejecute JavaScript y realice una redirección a destiempo.
El estándar de ingeniería actual se basa en un modelo riguroso de defensa en profundidad (Defense in Depth):
La seguridad de la aplicación se diseña como un perímetro de defensa escalonado por capas:
Petición HTTP entrante del cliente
│
▼
[Capa 1: Edge Middleware] ──────────────────────► Rechazo: HTTP 307 Redirect (/login)
(Comprobación criptográfica rápida de JWT en Edge)
│ Sesión válida
▼
[Capa 2: Server Layout / Page] ─────────────────► Rechazo: redirect('/unauthorized')
(Validación del rol del usuario: session.user.role)
│ Renderizado de interfaz protegida
▼
[Capa 3: Server Action Guard] ──────────────────► Rechazo: throw new Error('Forbidden')
(Validación aislada de session dentro de la mutación)
│ Acceso autorizado
▼
Ejecución de la transacción en la base de datosEl usuario envía sus credenciales o hace clic en el enlace del proveedor OAuth.
Auth.js genera desafíos PKCE, parámetros state y redirige al proveedor de identidad (Google/GitHub).
El proveedor redirige a /api/auth/callback/[provider]. El servidor intercambia el código por tokens.
Auth.js empaqueta el perfil y roles en una HttpOnly Cookie cifrada con prefijo __Secure-.
El navegador envía la cookie en cada petición; Edge Middleware y RSC verifican permisos.
Un desarrollador integra autenticación en Next.js 15: crea un archivo unificado auth.ts con PrismaAdapter e importa auth() en middleware.ts. Al desplegar en Vercel, la compilación falla con 'The edge runtime does not support Node.js net module'. ¿Cuál es el origen y la solución arquitectónica correcta?
En entornos estrictos de TypeScript, forzar tipos mediante as string introduce fragilidad. Configure un archivo de declaraciones en la raíz:
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;
}
}Para gestionar los callbacks de proveedores OAuth y endpoints de sesión, Auth.js expone controladores dinámicos unificados:
import { handlers } from '@/auth';
export const { GET, POST } = handlers;Middleware intercepta y rechaza peticiones no autorizadas antes de que se inicie el renderizado del servidor, reduciendo el consumo de cómputo a cero:
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).*)'],
};En Server Components, la verificación de sesión se realiza mediante la llamada directa a 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') {
// El usuario está autenticado pero carece de permisos suficientes
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">
Consola de administración del sistema
</h1>
<p className="text-text-secondary">
Bienvenido, {session.user.name} ({session.user.email}). Acceso verificado.
</p>
</main>
);
}Cada Server Action constituye un endpoint de red público y expuesto. Middleware no previene la ejecución de llamadas directas mediante RPC:
'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. Verificación obligatoria de la identidad del emisor
const session = await auth();
if (!session?.user) {
return { status: 'error', message: 'Error de autenticación: inicie sesión' };
}
// 2. Control de privilegios según rol (RBAC)
if (session.user.role !== 'ADMIN') {
return { status: 'error', message: 'Acceso denegado: se requieren permisos de administrador' };
}
// 3. Validación de datos de entrada
const parsed = DeleteUserSchema.safeParse({
userId: formData.get('userId'),
});
if (!parsed.success) {
return { status: 'error', message: 'Identificador de usuario no válido' };
}
try {
await db.user.delete({
where: { id: parsed.data.userId },
});
revalidatePath('/admin/users');
return { status: 'success', message: 'Usuario eliminado correctamente' };
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Error de base de datos desconocido';
console.error('[DeleteUserAction Failed]:', errorMsg);
return { status: 'error', message: 'No se pudo completar la eliminación' };
}
}En widgets interactivos del cliente (como selectores de perfil y menús de usuario), utilice SessionProvider junto con el hook useSession():
'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">
Iniciar sesión
</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"
>
Cerrar sesión
</button>
</div>
);
}Un desarrollador construye una ruta privada en /admin/users validando el rol de administrador en middleware.ts. La página contiene un formulario de eliminación que invoca la Server Action deleteUserAction. Dentro de dicha acción no se realiza ninguna comprobación porque 'un usuario sin permisos no puede acceder a esta pantalla'. ¿Qué vulnerabilidad crítica presenta esta solución?
Es un error habitual suponer que porque la ruta /admin esté protegida en middleware.ts, las Server Actions invocadas desde esa vista heredan dicha protección automáticamente.
Esto es una vulnerabilidad crítica. En Next.js, cada Server Action se compila en un endpoint HTTP POST público con un identificador único Next-Action. Cualquier cliente puede emitir una solicitud POST dirigida a dicho identificador utilizando fetch o curl, omitiendo por completo la interfaz gráfica.
Solución: Cada Server Action debe ejecutar const session = await auth() y validar los privilegios antes de aplicar cualquier mutación.
El Middleware de Next.js se ejecuta exclusivamente en el Edge Runtime, el cual no incluye módulos nativos de Node.js (net, tls, child_process) ni sockets TCP directos. Importar Prisma o un adaptador de base de datos directamente en middleware.ts provocará errores fatales de compilación.
Solución: Segregue la configuración en auth.config.ts (verificaciones JWT aptas para Edge) y auth.ts (entorno Node.js completo con PrismaAdapter).
Al expirar el Access Token, varias peticiones simultáneas de la misma página pueden disparar la rotación del Refresh Token de forma concurrente en el callback jwt. Si el proveedor de OAuth invalida de inmediato el Refresh Token de un solo uso en el primer intercambio, la segunda petición fallará y cerrará la sesión del usuario de forma imprevista.
Solución: Configure un periodo de gracia (15 a 30 segundos) en el proveedor o coordine las renovaciones mediante un cerrojo distribuido (Mutex) en Redis.
Validar permisos en hooks de cliente como useEffect permite que el servidor genere y envíe el HTML con información sensible al cliente durante el SSR antes de que el código JavaScript se ejecute y dispare router.push('/login'). Cualquier inspección del código fuente transmitido expondrá los datos protegidos.
Solución: Aplique la verificación en el servidor a través de middleware.ts y en layout.tsx / page.tsx mediante await auth().