Авторизация и сессия в Next.js
Документ про фронтенд, который получает сессию от бэкенда (токены или
сессионный id) и должен хранить её, продлевать и проверять. Если проект
использует библиотеку или провайдера (Auth.js, Better Auth, Clerk и т. п.) —
механику сессии берёт она, но правила разделов 2, 6–10 остаются в силе.
Код — references/implementation.md.
Приоритет правил. Инструкции пользователя и agent-файлы проекта важнее. Если бэкенд диктует иное (например, сам ставит cookie) — следуй бэкенду и запиши отличие в README или ADR.
Обозначения: MUST — нарушать нельзя; SHOULD — по умолчанию да, отступление требует причины.
1. Выбор модели
| Модель | Когда |
|---|---|
| BFF: route handlers Next получают токены от бэкенда и кладут в HttpOnly-cookie | Бэкенд отдаёт токены в теле ответа. Вариант по умолчанию для Next |
| Бэкенд сам ставит HttpOnly-cookie | Фронтенд и API на одном сайте, бэкенд под твоим контролем |
| Библиотека или провайдер | Нужны OAuth, MFA, управление пользователями — не пиши это сам |
| Access в памяти, refresh в HttpOnly-cookie | SPA без BFF |
Токены в localStorage/sessionStorage | Запрещено: любой XSS уносит сессию |
Вторая развилка — что проксирует BFF:
| Вариант | Плюсы | Минусы |
|---|---|---|
| Только login / refresh / logout, остальные запросы идут на API напрямую с cookie | BFF не узкое место; меньше кода | API читает токен из cookie и настраивает CORS; cookie должны долетать до домена API |
| Все запросы через BFF | API не знает про cookie и CORS; один origin | Каждый запрос — лишний хоп; BFF надо масштабировать |
Выбор записывается в ADR.
2. Cookie (MUST)
HttpOnlyдля всего, что даёт доступ (access, refresh, id сессии).Secureв продакшене. Локально браузеры разрешаютSecureнаhttp://localhost, но не на других http-хостах.SameSite=Laxпо умолчанию.Strictнадёжнее против CSRF, но пользователь, пришедший по ссылке из письма или с другого сайта, на первом переходе выглядит неавторизованным — выбирай осознанно и запиши причину.Path=/; срок жизни — изexpтокена или из ответа бэкенда, не «на глаз».- SHOULD: префикс
__Host-в имени (требуетSecure,Path=/, безDomain) — cookie нельзя подменить с поддомена. - Cookie ставятся и удаляются только на сервере (route handler, Server
Action,
proxy.ts). Server Components cookie ставить не могут. - Нечитаемая из JS cookie — единственный носитель сессии. Флаг вида
is_authenticatedбезHttpOnlyдопустим только как подсказка для UI и никогда не используется для решений о доступе.
3. BFF: route handlers (MUST)
- Вход валидируется схемой (
zod); тело с неверной формой —400. - Мутирующие обработчики проверяют
Originпротив origin приложения:SameSite=Laxне защищает от запросов с поддоменов того же сайта. Server Actions сравниваютOriginсHostсами. - Клиентский контекст пробрасывается бэкенду:
X-Forwarded-For,User-Agent— иначе rate limit бэкенда видит только IP сервера. - Ошибки бэкенда переводятся в ответ с
kindи статусом (401,403,422,429сRetry-After,503), текст бэкенда наружу не уходит. - Refresh: если бэкенд отклонил refresh-токен — удалить cookie и
ответить
401. Если бэкенд недоступен (сеть, 5xx) — cookie не трогать, ответить503: временный сбой не должен разлогинивать. - Logout: отозвать токен на бэкенде (ошибку залогировать, но не показывать), cookie удалить в любом случае.
4. Где проверять сессию (MUST)
| Место | Что делает | Чего не делает |
|---|---|---|
proxy.ts (Next 16+, раньше middleware.ts) | Оптимистичная проверка по наличию cookie: редирект на вход и со страниц входа | Не ходит в БД и API; не единственная защита |
Data Access Layer (verifySession() в server-only модуле) | Проверка рядом с данными: в серверных компонентах, route handlers, Server Actions | — |
| Лейаут | Ничего: лейауты не перерисовываются при навигации | Не проверяет доступ |
| Бэкенд | Источник истины: проверяет токен на каждом запросе | — |
- Каждый route handler и Server Action — публичная точка входа и сам
вызывает
verifySession(). proxy.tsпроверяет refresh-cookie (или id сессии): access мог истечь, а сессия жива.- Совпадение пути — по сегментам:
/appи/app/…, но не/apple. - С
cacheComponents(по умолчанию в проектах на Next 16.4) у чтения сессии и кэширования данных пользователя свои правила: до того как писать DAL, прочитайnode_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md. - Права (роль, владение) решаются по проверенным данным: по подписи (JWKS,
jose) или по ответу бэкенда. Непроверенный payload JWT — не основание для доступа. - Server Components не могут обновить cookie. Если access истёк к моменту
серверного рендера: либо
proxy.tsобновляет сессию (он может ставить cookie в ответ — делай это только для навигаций документа, не для prefetch, и учти ротацию refresh-токенов), либо страница рендерит клиентскую загрузку и refresh делает клиент.
5. Клиент: refresh без гонок (MUST)
- Определение «не авторизован»: HTTP
401, либо в GraphQL-ответе (он200) хотя бы одна ошибка сextensions.code === "UNAUTHENTICATED"(errors.some, неerrors[0]). Сравнение с текстом сообщения — только если бэкенд не отдаёт кодов, в одной функции, с комментарием и тестом. - Single-flight: параллельные запросы, получившие 401, ждут один общий
refresh и используют его результат. Промис сбрасывается в
finally. - Исходный запрос повторяется один раз. Повторный 401 — сессия потеряна, без второго refresh.
- Сам запрос refresh не проходит через перехватчик (иначе рекурсия).
- Результат refresh различает три исхода: выдан (
true) → повторить; отклонён (false) → сессия истекла; сеть или 5xx (исключение) → ошибкаnetwork, без выхода из аккаунта. - Во время выхода refresh заблокирован: иначе запрос, упавший с 401 в момент logout, тут же восстановит сессию.
- Истекшая сессия: очистить кэш запросов, увести на вход с
callbackUrlтекущей страницы. - Тело повторяемого запроса должно быть повторяемым (строка,
FormData), не одноразовыйReadableStream.
6. Редирект после входа (MUST)
callbackUrl(next,returnTo) проверяется функциейgetSafeRedirect(raw, fallback, appOrigin): отклоняет\и управляющие символы (браузеры превращают/\evil.comи/<tab>/evil.comв//evil.com), разбираетnew URL(raw, appOrigin), сравнивает origin и возвращает только путь. Проверка «начинается с/и не с//» — недостаточна.- Набор тестов на неё —
references/implementation.md, раздел 7. - После входа серверные компоненты должны увидеть новые cookie: полная
навигация (
window.location.assign) илиrouter.replace+router.refresh().
7. Выход (MUST)
- Сервер удаляет cookie (раздел 3).
- Клиент очищает кэш запросов (
queryClient.clear()) и все клиентские сторы с данными пользователя: следующий пользователь в той же вкладке не должен увидеть чужие данные даже на мгновение. - Навигация на страницу входа без возможности вернуться «назад» в
закэшированный защищённый экран (
replaceили полная навигация). - Подтверждение выхода — по желанию продукта; блокировка кнопки на время запроса — обязательно.
- SHOULD: другие вкладки узнают о выходе (
BroadcastChannelили событиеstorageпо флагу), иначе они продолжают показывать данные до первого 401.
8. Токены в ссылках (сброс пароля, подтверждение email)
- Токен читается из URL один раз в состояние, затем адрес заменяется без
токена (
router.replace(pathname)): он не остаётся в истории и не уезжает вReferer. - Для этих страниц —
Referrer-Policy: no-referrerи никаких сторонних скриптов (аналитика, виджеты). - Токен не логируется. Одноразовость и срок жизни обеспечивает бэкенд; фронтенд показывает понятный экран «ссылка устарела» с действием «запросить новую».
9. Формы входа и восстановления
- Восстановление пароля отвечает одинаково для существующего и несуществующего email («если аккаунт есть, письмо отправлено»), не раскрывая, кто зарегистрирован. Если бэкенд раскрывает — записать как ограничение.
- Отправка блокируется на время запроса; повторная отправка не создаёт второй запрос.
429: сообщение с временем ожидания изRetry-After, если оно есть.- Поля:
type="email"+autoComplete="email"; пароль —autoComplete="current-password"при входе и"new-password"при регистрации и смене; вставка не блокируется. - Кнопка «показать пароль» —
type="button",aria-labelиaria-pressed, не забирает фокус у поля. - Ошибка «неверный email или пароль» не уточняет, что именно неверно.
- Ошибки сервера сопоставляются полям по коду; общая — над формой.
10. Данные и логи
- Ответы, зависящие от пользователя, не попадают в общие кэши
(
"use cache", CDN). QueryClient на сервере — новый на каждый запрос. - В логах нет токенов, паролей, email. Максимум — id пользователя и
kindошибки. - Payload сессионного токена минимален: id и роль, без персональных данных.
11. Тесты
- Unit:
getSafeRedirect(весь набор из references); определение «не авторизован» (401, GraphQL-коды, пустое тело, HTML вместо JSON). - Single-flight: N параллельных запросов с 401 → один refresh, N
повторов; refresh отклонён → ни одного повтора, один вызов
onSessionExpired; refresh упал по сети → ошибкаnetwork, без выхода; повторный 401 после успешного refresh → без второго refresh; во время logout → без refresh. - Route handlers: флаги cookie; refresh отклонён → cookie удалены; бэкенд
недоступен → cookie на месте,
503; неверное тело →400; чужойOrigin→403. - e2e: вход → защищённая страница → выход → защищённая страница недоступна; истечение access (короткий TTL в тестовом окружении) → прозрачный refresh; ссылка сброса пароля → токен исчез из адресной строки.
12. Definition of Done
- Ни один токен не читается из JS;
localStorageне используется для сессии. - Cookie:
HttpOnly,Secureв проде,SameSite,Path, срок из токена. - Refresh: отклонение и недоступность бэкенда различаются.
- Single-flight с результатом, один повтор, блокировка во время logout — покрыто тестами.
-
proxy.ts— только оптимистичная проверка; DAL вызывается в handlers, actions и серверных компонентах. -
getSafeRedirectс полным набором тестов. - Выход очищает кэш и сторы клиента.
- Токены из ссылок убираются из адресной строки.
- В логах нет токенов и персональных данных.
- Выбор модели и прокси записан в ADR.