Автентифікація (перевірка автентичності суб'єкта) та авторизація (розмежування прав доступу до ресурсів) — базовий фундамент корпоративної безпеки вебдодатків. У застарілих клієнтських SPA-архітектурах (React + Vite) сесійні токени часто зберігалися в localStorage, а захист приватних сторінок реалізовувався через клієнтські хуки всередині useEffect.
В архітектурі Next.js App Router ця модель визнана критичною вразливістю. Оскільки рендеринг сторінки стартує на сервері, клієнтська перевірка неминуче призведе до витоку даних: сервер спочатку згенерує і надішле в мережу конфіденційну HTML-розмітку, а потім браузер виконає JavaScript і здійснить запізнілий редирект.
Сучасний інженерний стандарт спирається на модель ешелонованої оборони (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>
);
}Розробник створив захищений маршрут /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-розмітку клієнту під час SSR, а потім браузер виконує JS і запускає router.push('/login'). Будь-хто, хто переглядає вихідний код відповіді, побачить приватні дані.
Рішення: Забезпечуйте перевірку на сервері в middleware.ts та кореневих layout.tsx / page.tsx через await auth().