Аутентификация (проверка подлинности субъекта) и авторизация (разграничение прав доступа к ресурсам) — базовый фундамент корпоративной безопасности веб-приложений. В устаревших клиентских SPA-архитектурах (React + Vite) сессионные токены часто сохранялись в localStorage, а защита приватных страниц реализовывалась через клиентские хуки внутри useEffect.
В архитектуре Next.js App Router эта модель признана критической уязвимостью. Поскольку рендеринг страницы стартует на сервере, клиентская проверка неизбежно приведет к утечке: сервер сначала сгенерирует и отправит в сеть конфиденциальную HTML-разметку, а затем браузер исполнит JavaScript и выполнит запоздалый редирект.
Современный стандарт 2026 года опирается на модель эшелонированной обороны (Defense in Depth):
Защита приложения строится по принципу многоуровневого периметра безопасности:
Входящий HTTP-запрос клиента
│
▼
[Уровень 1: Edge Middleware] ───────────────────► Отказ: HTTP 307 Redirect (/login)
(Быстрая проверка наличия JWT-куки в Edge)
│ Сессия валидна
▼
[Уровень 2: Server Layout / Page] ─────────────► Отказ: redirect('/unauthorized')
(Проверка роли пользователя session.user.role)
│ Рендеринг защищенного UI
▼
[Уровень 3: Server Action Guard] ──────────────► Отказ: throw new Error('Forbidden')
(Изолированная валидация session внутри мутации)
│ Доступ разрешен
▼
Выполнение транзакции в базе данныхПользователь отправляет форму входа или переходит по ссылке провайдера.
Auth.js генерирует PKCE-код, state и перенаправляет на сервер авторизации (Google/GitHub).
Провайдер возвращает auth code на /api/auth/callback/[provider]. Сервер запрашивает токены.
Auth.js упаковывает данные профиля и роли в зашифрованную HttpOnly Cookie с префиксом __Secure-.
Браузер отправляет куку при каждом запросе; Edge Middleware и RSC проверяют права.
Разработчик добавил аутентификацию в Next.js 15: создал единый файл auth.ts с PrismaAdapter и импортировал auth() в middleware.ts. При деплое на Vercel сборка падает с ошибкой 'The edge runtime does not support Node.js net module'. В чем причина и как архитектурно правильно решить проблему?
В строгом TypeScript запрещено приводить поля сессии вручную через as string. В корне проекта создается декларация типов:
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;
}
}Для обслуживания OAuth-коллбеков и эндпоинтов сессии Auth.js генерирует единый динамический обработчик:
import { handlers } from '@/auth';
export const { GET, POST } = handlers;Middleware отсекает неавторизованные запросы до начала серверного рендеринга страницы, обеспечивая нулевую нагрузку на вычислительные ресурсы приложения.
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).*)'],
};В серверных компонентах проверка сессии выполняется через прямой вызов 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') {
// Пользователь авторизован, но его уровень привилегий недостаточен
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">
Консоль системного администратора
</h1>
<p className="text-text-secondary">
Добро пожаловать, {session.user.name} ({session.user.email}). Доступ подтвержден.
</p>
</main>
);
}Каждый Server Action является открытым сетевым эндпоинтом. Middleware не гарантирует безопасность прямых 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. Обязательная проверка подлинности вызывающего субъекта
const session = await auth();
if (!session?.user) {
return { status: 'error', message: 'Ошибка авторизации: выполните вход' };
}
// 2. Проверка ролевых привилегий (RBAC)
if (session.user.role !== 'ADMIN') {
return { status: 'error', message: 'Отказ в доступе: требуются права администратора' };
}
// 3. Валидация входных данных
const parsed = DeleteUserSchema.safeParse({
userId: formData.get('userId'),
});
if (!parsed.success) {
return { status: 'error', message: 'Некорректный идентификатор пользователя' };
}
try {
await db.user.delete({
where: { id: parsed.data.userId },
});
revalidatePath('/admin/users');
return { status: 'success', message: 'Пользователь успешно удален' };
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Неизвестная ошибка БД';
console.error('[DeleteUserAction Failed]:', errorMsg);
return { status: 'error', message: 'Не удалось выполнить удаление' };
}
}В изолированных клиентских сценариях (интерактивный аватар в навигации, состояние загрузки) используется SessionProvider и хук 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">
Войти
</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"
>
Выйти
</button>
</div>
);
}Junior-разработчик создал защищенный маршрут /admin/users с проверкой роли администратора в middleware.ts. Внутри страницы есть форма удаления пользователя, вызывающая Server Action deleteUserAction. Внутри самого Action проверка прав отсутствует, так как 'пользователь без прав админа не может открыть эту страницу'. В чем критическая уязвимость этого решения?
Распространенное заблуждение: «Маршрут /admin защищен в middleware.ts, значит серверные экшены внутри этой страницы защищены автоматически».
Это критическая уязвимость! В Next.js каждый Server Action компилируется в независимый публичный HTTP POST-эндпоинт с уникальным хешем Next-Action. Любой пользователь может направить POST-запрос с полезной нагрузкой напрямую к этому Action через fetch или curl, полностью минуя страницу и интерфейс.
Решение: Каждый Server Action обязан вызывать const session = await auth() и самостоятельно проверять права.
Next.js Middleware выполняется в легковесном рантайме Edge Runtime, который не поддерживает стандартные модули Node.js (net, tls, child_process) и прямые TCP-соединения к SQL-базам. Если импортировать инстанс NextAuth с PrismaAdapter прямо в middleware.ts, сборка завершится фатальной ошибкой.
Решение: Разделяйте конфигурацию на auth.config.ts (чистый Edge с валидацией JWT) и auth.ts (полный Node.js с PrismaAdapter).
Когда время жизни Access Token истекает, несколько параллельных запросов страницы могут одновременно запустить Refresh Token Rotation в коллбеке jwt. Если провайдер OAuth (Google, Auth0) строго инвалидирует одноразовый Refresh Token при первом обращении, второй параллельный запрос завершится отказом и неожиданно разлогинит пользователя.
Решение: Настраивайте временное окно устаревания (grace-period 15–30 секунд) на стороне провайдера либо используйте блокировку через Redis Mutex.
Проверка прав внутри клиентских хуков useEffect на странице: сервер сначала генерирует и отправляет защищенный HTML-разметку клиенту, а затем браузер исполняет JS и выполняет router.push('/login').