В предыдущих версиях Next.js при обнаружении неаутентифицированного пользователя или недостатка прав разработчики прибегали к двум обходным решениям:
redirect('/login') — принудительный переход, стирающий контекст текущего экрана и ломающий композицию интерфейса при частичном рендеринге слотов.notFound() — возврат HTTP-статуса 404, маскирующий существование ресурса. Хотя это полезно для предотвращения атак перебора идентификаторов (ID Enumeration), это искажало семантику аудита, аналитику ошибок и делало невозможным отображение специализированного экрана запроса доступа или повторного ввода пароля.В Next.js 16 представлена парадигма прерываний авторизации (Auth Interrupts). Активируемый флагом experimental.authInterrupts: true, этот механизм вводит две новые управляющие функции в пакете next/navigation:
unauthorized() — генерирует прерывание с кодом HTTP 401 Unauthorized (субъект не идентифицирован).forbidden() — генерирует прерывание с кодом HTTP 403 Forbidden (субъект идентифицирован, но доступ запрещен политикой прав).Каждая из этих функций перехватывается специализированной декларативной файловой границей: unauthorized.tsx или forbidden.tsx соответственно. Граница монтируется на уровне ближайшего родительского сегмента маршрута, сохраняя целостность корневого каркаса (навигации, сайдбара и шапки приложения).
Прерывание авторизации работает по аналогии с notFound() и механизмом React Suspense / Error Boundary. Когда внутри любого серверного компонента или слоя доступа к данным вызывается unauthorized() или forbidden(), Next.js выбрасывает специальное внутреннее прерывание (Sentinel Error). Выполнение компонента немедленно прекращается, а диспетчер App Router выполняет поиск ближайшего обработчика вверх по иерархии файловой системы.
app/
├── layout.tsx (Корневой каркас: Sidebar, Navigation)
├── unauthorized.tsx (Глобальный fallback для 401)
├── forbidden.tsx (Глобальный fallback для 403)
└── dashboard/
├── layout.tsx (Макет рабочей области)
├── forbidden.tsx (Локальная граница 403 для Dashboard)
└── billing/
└── page.tsx ──► forbidden()
│
│ (Прерывание всплывает вверх)
▼
[Перехвачено в app/dashboard/forbidden.tsx]
(Sidebar и шапка Dashboard остаются интерактивными!)Для принятия решения о вызове forbidden() в современных корпоративных системах применяются две модели:
finance_admin могут открывать раздел биллинга».doc.authorId === user.id) И документ находится в статусе draft».В файле next.config.ts включается experimental: { authInterrupts: true }, активирующий резолверы unauthorized.tsx и forbidden.tsx.
Серверный компонент обращается к DAL для чтения сущности и проверки прав доступа текущего пользователя.
При отсутствии валидной сессии вызывается unauthorized(), а при нарушении правил RBAC/ABAC — forbidden().
Next.js прерывает стриминг целевого сегмента и ищет ближайший файл unauthorized.tsx или forbidden.tsx вверх по дереву папок.
Компонент границы рендерится строго внутри слота текущего сегмента. Родительские макеты не перерисовываются.
В каком сценарии архитектурно корректно вызывать функцию forbidden(), а не unauthorized()?
Пользователь с учетной записью сотрудника филиала пытается перейти по прямой ссылке в /settings/billing. Серверный компонент проверяет роль: if (!hasRole(user, 'org_owner')) forbidden(). Отрисовывается сегмент forbidden.tsx с кнопкой «Запросить повышение привилегий у владельца организации», при этом сайдбар и профиль пользователя не перезагружаются.
В системе документооборота пользователь имеет общую роль editor. Однако редактировать конфиденциальный договор разрешено только непосредственному автору (contract.createdById === user.id) и только пока документ не подписан юридическим отделом (contract.status === 'in_review'). При попытке изменения чужого документа DAL выбрасывает forbidden().
При попытке скомпрометировать или экспортировать базу клиентов система проверяет время последней аутентификации. Если прошло более 15 минут с момента ввода пароля, вызывается unauthorized(). Локальный компонент unauthorized.tsx отображает модальное окно повторного ввода биометрии/2FA без потери несохраненных данных на форме.
При обращении сторонних клиентов через браузер или мобильное приложение функции unauthorized() и forbidden() отдают строгие заголовки ответа WWW-Authenticate и соответствующие статус-коды 401 и 403, исключая редиректы 302/307, которые ломают автоматизированных API-клиентов.
Next.js инициализирует внутренние классы ошибок UnauthorizedError и ForbiddenError при старте сервера.
Требует experimental.authInterrupts в next.config.ts.
Функция assertCanAccessDocument() вычисляет предикаты RBAC/ABAC на основе контекста пользователя.
Проверка выполняется до обращения к тяжелым связям базы данных.
Вызов forbidden() генерирует специальный сигнал прерывания рендеринга текущего сегмента React Flight.
Исполнение дочерних компонентов немедленно прерывается.
Диспетчер роутера выполняет подстановку компонента forbidden.tsx на место сбойного сегмента page.tsx.
Родительский layout.tsx сохраняет свое состояние и контекст.
next.config.ts#Для включения поддержки специализированных файлов unauthorized.tsx и forbidden.tsx необходимо активировать флаг компилятора:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
// Активирует поддержку unauthorized(), forbidden(), unauthorized.tsx и forbidden.tsx
authInterrupts: true,
},
};
export default nextConfig;lib/auth/permissions.ts#Объявляем матрицу ролей (RBAC) и предикаты контекста (ABAC).
import 'server-only';
import { UserSession } from '@/lib/dal/auth';
export type Role = 'admin' | 'billing_manager' | 'editor' | 'viewer';
export type Permission =
| 'org:delete'
| 'billing:manage'
| 'doc:create'
| 'doc:update'
| 'doc:delete';
// Матрица RBAC: сопоставление роли и статических прав
const ROLE_PERMISSIONS: Record<Role, Set<Permission>> = {
admin: new Set(['org:delete', 'billing:manage', 'doc:create', 'doc:update', 'doc:delete']),
billing_manager: new Set(['billing:manage', 'doc:create']),
editor: new Set(['doc:create', 'doc:update']),
viewer: new Set([]),
};
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.has(permission) ?? false;
}
// Контракт сущности документа для проверки ABAC
export interface DocumentEntity {
id: string;
tenantId: string;
authorId: string;
isLocked: boolean;
department: string;
}
/**
* Предикат ABAC: проверяет права на редактирование конкретного документа
* Учитывает роль, принадлежность тенанту, авторство и состояние документа
*/
export function canEditDocument(user: UserSession, doc: DocumentEntity): boolean {
// Тенантная изоляция: доступ к чужому тенанту категорически запрещен
if (user.tenantId !== doc.tenantId) {
return false;
}
// Заблокированные документы может редактировать только администратор
if (doc.isLocked && user.role !== 'admin') {
return false;
}
// Администратор организации имеет безусловный доступ
if (user.role === 'admin') {
return true;
}
// Редактор может изменять только свои собственные документы
if (user.role === 'editor' && doc.authorId === user.userId) {
return true;
}
return false;
}app/(workspace)/documents/[id]/page.tsx#Использование unauthorized() и forbidden() внутри асинхронного серверного компонента с параметризованным маршрутом.
import { unauthorized, forbidden, notFound } from 'next/navigation';
import { verifySession } from '@/lib/dal/auth';
import { canEditDocument } from '@/lib/auth/permissions';
import { db } from '@/lib/db';
interface DocumentPageProps {
params: Promise<{ id: string }>;
}
export default async function DocumentEditPage(props: DocumentPageProps) {
const { id } = await props.params;
// 1. Проверка аутентификации (401)
const session = await verifySession();
if (!session) {
unauthorized();
}
// 2. Получение сущности из БД
const document = await db.documents.findUnique({
where: { id },
select: {
id: true,
title: true,
content: true,
authorId: true,
tenantId: true,
isLocked: true,
department: true,
},
});
// 3. Если документ физически не существует — отдаем 404
if (!document) {
notFound();
}
// 4. Проверка контекстной авторизации ABAC (403)
if (!canEditDocument(session, document)) {
forbidden();
}
return (
<article className="p-6">
<h1 className="text-2xl font-bold">{document.title}</h1>
<div className="mt-4 prose dark:prose-invert">
{document.content}
</div>
</article>
);
}unauthorized.tsx и forbidden.tsx#Компоненты границ размещаются в папке сегмента и рендерятся сервером при возникновении прерывания.
import Link from 'next/link';
export default function ForbiddenBoundary() {
return (
<div className="flex flex-col items-center justify-center p-12 text-center border border-red-500/20 bg-red-950/10 rounded-xl my-6">
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center mb-4 text-red-400 font-bold">
403
</div>
<h2 className="text-lg font-bold text-slate-100">Доступ ограничен политикой безопасности</h2>
<p className="text-sm text-slate-400 max-w-md mt-2">
Ваша учетная запись не обладает достаточными полномочиями для просмотра или изменения данного ресурса.
</p>
<div className="mt-6 flex gap-3">
<Link
href="/dashboard"
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg transition"
>
Вернуться в консоль
</Link>
</div>
</div>
);
}import Link from 'next/link';
export default function UnauthorizedBoundary() {
return (
<div className="flex flex-col items-center justify-center p-12 text-center border border-amber-500/20 bg-amber-950/10 rounded-xl my-6">
<div className="w-12 h-12 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center mb-4 text-amber-400 font-bold">
401
</div>
<h2 className="text-lg font-bold text-slate-100">Требуется повторная аутентификация</h2>
<p className="text-sm text-slate-400 max-w-md mt-2">
Срок действия сессии истек или сессионный токен не предоставлен. Пожалуйста, выполните вход в систему.
</p>
<div className="mt-6">
<Link
href="/login"
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 text-white font-semibold rounded-lg transition shadow-lg shadow-amber-600/20"
>
Войти в аккаунт
</Link>
</div>
</div>
);
}Если функция forbidden() вызвана внутри компонента app/dashboard/settings/page.tsx, а файл forbidden.tsx объявлен только в корне app/dashboard/forbidden.tsx, как отреагирует App Router?
Стремясь скрыть существование сущностей, разработчики повсеместно вызывают notFound() вместо forbidden(). Это приводит к путанице для легитимных пользователей: сотрудник считает, что ссылка сломана или файл удален, вместо понимания необходимости запросить права у администратора.
Как избежать: Используйте notFound() только для объектов, факт существования которых является строго охраняемой тайной. Для авторизованных корпоративных интерфейсов возвращайте forbidden().
Если вызвать forbidden() или unauthorized() без включения флага experimental: { authInterrupts: true }, Next.js выбросит необработанное исключение рантайма NEXT_HTTP_ERROR_FALLBACK, которое будет перехвачено общим error.tsx со статусом 500 Internal Server Error.
Как избежать: Всегда активируйте флаг authInterrupts: true в конфигурации проекта перед использованием нового API.
Оборачивание вызова серверного репозитория в общий блок try { ... } catch (e) { return null; } перехватывает служебные прерывания Next.js, прерывая механизм всплытия к unauthorized.tsx или forbidden.tsx.
Как избежать: При использовании блоков try/catch на сервере проверяйте тип ошибки или повторно выбрасывайте системные исключения Next.js.
Скрытие кнопки «Удалить» в UI через клиентское условие user.role === 'admin' без дублирования проверки в Server Action позволяет любому пользователю отправить POST-запрос с идентификатором экшена и произвести удаление.
Как избежать: Рассматривайте UI только как средство отображения. Полномочия обязаны проверяться на сервере внутри каждого Server Action и метода DAL.