frontend-auth-tokens.md — vim

Аутентификация на фронте: от localStorage до единого входа

Где хранить JWT, почему httpOnly-cookie не бесплатна, как пережить пять параллельных 401, чем разлогинить по-настоящему и что ломает silent SSO в Keycloak.


Логин — обманчиво простая часть приложения. Форма, запрос, токен в ответе, localStorage.setItem — работает же.

Потом выясняется, что access живёт пятнадцать минут, вкладок у человека открыто пять, кнопка «выйти» на самом деле не выходит, а через год приходит требование сделать единый вход для трёх продуктов сразу.

Короткий ответ, если ты пришёл сюда из поиска: access-токен держи в памяти, refresh — в httpOnly-куке, а лучше не отдавай токены в браузер вообще и поставь перед фронтом тонкий бэкенд. Дальше — почему именно так, чем за это платишь и что ломается на каждом шаге.

Опираться буду на два документа, которые стоит хотя бы пролистать: RFC 9700 — общая безопасность OAuth, BCP от января 2025 — и RFC 10017, отдельный BCP именно про браузерные приложения. Второй особенно полезен тем, что несколько «лучших практик», до сих пор кочующих по статьям, там прямо помечены как устаревшие.

Где на самом деле хранить токен

Вопрос обычно ставят как «localStorage или cookie». RFC 10017 начинает с другого: а должен ли токен вообще оказаться в браузере.

СпособЧто видит JSXSSCSRFЦена
Backend-for-Frontendничеготокен не украстьрешается SameSiteнужен свой бэкенд
Access в памяти + refresh в кукеaccess, пока вкладка живаутечёт access на 15 минутрешается SameSiteсброс при перезагрузке
Всё в localStorageвсёутекает всё, включая refreshне подверженопростота, и на этом плюсы кончились

Backend-for-Frontend

Токены живут на сервере, браузер получает обычную сессионную httpOnly-куку. JavaScript до токена не дотягивается в принципе, потому что токена в браузере нет: фронт ходит на свой же бэкенд, тот подставляет токен и проксирует запрос дальше.

Это рекомендация BCP по умолчанию. Если у вас и так есть свой сервер — например, вы уже рендерите страницы на Nuxt, — брать стоит именно её.

Токен в памяти

Когда отдельного бэкенда нет: access держим в переменной модуля, refresh — в httpOnly-куке. Перезагрузка страницы теряет access, и это нормально, он восстанавливается по refresh при старте приложения.

Главное — не класть его в стор, который сериализуется в localStorage ради «удобного восстановления состояния». Так делают часто и обнуляют этим всю затею.

localStorage, который советуют зря

То, что написано в большинстве туториалов, и то, чего BCP советует избегать: любой XSS читает токен одной строчкой и уносит куда угодно. Хуже всего здесь не access, который протухнет через четверть часа, а refresh — он живёт неделями и позволяет выпускать новые access сколько угодно.

Почему хранилище — это глубина обороны, а не защита

Важный момент, который меняет весь разговор. XSS означает исполнение чужого кода в вашем origin. Даже если токен спрятан идеально, атакующий не станет его искать — он просто запросит новый, от имени пользователя, через ваш же код.

Поэтому выбор хранилища ограничивает ущерб, но не предотвращает его. Единственная настоящая защита от XSS — не иметь XSS: CSP, экранирование, никакого v-html на пользовательских данных. Всё остальное — про то, насколько дорого обойдётся пробитие.

И отдельно, потому что повторять приходится регулярно: VITE_-переменные не секрет. Всё, что попало в бандл, видно через DevTools.

Куку нельзя прочитать из JavaScript — но браузер сам приложит её к запросу, включая тот, который инициировал чужой сайт. Это CSRF, и он приезжает в комплекте.

SameSite, Secure и префикс __Host-

  • SameSite=Lax как минимум, Strict там, где переходы по внешним ссылкам не нужны. Lax закрывает основную массу сценариев, но не все;
  • Secure — куку нельзя отправить по HTTP;
  • префикс __Host- — тогда куку нельзя подложить с соседнего поддомена. Про это забывают, а зря: на больших доменах с десятком сервисов поддомен чаще всего и оказывается слабым местом.

Когда нужен отдельный CSRF-токен

Если бэкенд принимает только application/json и проверяет Content-Type, простая форма с чужого сайта до него не доедет — браузер не даст отправить такой запрос без CORS-преflight. Как только появляется приём form-urlencoded или файловые загрузки, нужен явный CSRF-токен на изменяющие запросы.

Итог простой: localStorage меняет CSRF на XSS, cookie меняет XSS на CSRF. Разница в том, что CSRF закрывается конфигурацией один раз, а XSS — дисциплиной всей команды на всём проекте и навсегда.

Как читать JWT на фронте и не обмануться

Токен хочется разобрать: показать имя в шапке, спрятать кнопку без права, понять, когда истекает сессия. Это законно, но с тремя оговорками.

Подпись на фронте не проверяют. Смысла ноль: код, который проверяет, и ключ, которым проверяют, лежат у того же пользователя. Проверка подписи — работа бэкенда. На фронте декодирование нужно только для интерфейса.

exp — это секунды, а не миллисекунды. Классическая ошибка на полдня отладки:

// неверно: exp в секундах, Date.now() в миллисекундах
const expired = payload.exp < Date.now();

// верно
const expired = payload.exp * 1000 < Date.now();

Считай токен протухшим заранее. Часы на машине пользователя врут, сеть добавляет задержку. Запас в тридцать-шестьдесят секунд убирает целый класс плавающих 401:

const SKEW_MS = 30_000;

export function isExpired(payload: { exp: number }): boolean {
  return payload.exp * 1000 - SKEW_MS < Date.now();
}

И не храни в токене того, что не готов показать пользователю: payload — это base64, а не шифрование, читается в любой консоли за секунду.

Refresh: гонка на пять вкладок

Access истёк, а на экране таблица, фильтр и виджет уведомлений. Три запроса уходят одновременно и три получают 401. Наивный интерцептор запустит три refresh.

Дальше интересное. RFC 9700 требует для публичных клиентов ротацию: каждый refresh гасит предыдущий и выдаёт новый. Значит, первый из трёх обновится успешно, а второй и третий придут с уже погашенным токеном. Сервер сделает единственно верный вывод — токен украли — и погасит всю семью. Пользователя выкинет, причём воспроизводиться это будет только на медленной сети.

Один промис на все параллельные 401

let refreshing: Promise<string> | null = null;

export function refreshAccessToken(): Promise<string> {
  refreshing ??= authApi
    .refresh()
    .then(({ accessToken }) => {
      setToken(accessToken);
      return accessToken;
    })
    .finally(() => {
      refreshing = null;
    });

  return refreshing;
}

Приём здесь тот же, что и с очередью сообщений на время переподключения вебсокета — общий ресурс восстанавливается однократно, все остальные встают в очередь.

Повтор ровно один раз

Сам перехват — в слое API: поймали 401, дождались общего refresh, повторили исходный запрос. Ровно один раз — повтор без счётчика превращается в бесконечный цикл в тот день, когда протухнет и refresh.

client.interceptors.response.use(undefined, async (error) => {
  const config = error.config;

  if (error.response?.status !== 401 || config._retried) {
    return Promise.reject(normalizeError(error));
  }

  config._retried = true;
  await refreshAccessToken();
  return client(config);
});

Синхронизация между вкладками

Вкладка, обновившая токен, обязана сообщить остальным — иначе пять вкладок будут ротировать токен по кругу и гасить сессию друг другу:

const channel = new BroadcastChannel('auth');

channel.onmessage = (e) => {
  if (e.data.type === 'token') setToken(e.data.accessToken);
  if (e.data.type === 'logout') hardReset();
};

Разлогин тоже рассылаем: человек нажал «выйти» в одной вкладке, а в четырёх соседних остался открытым его рабочий стол — так себе поведение для приложения с чувствительными данными.

Состояние сессии — размеченное объединение

Описывай его размеченным объединением, а не парой булевых флагов:

type Session =
  | { status: 'anonymous' }
  | { status: 'authenticating' }
  | { status: 'authenticated'; user: User; expiresAt: number }
  | { status: 'expired' };

С двумя флагами рано или поздно получается «не залогинен, но загрузка идёт», и в этом состоянии половина экранов рисует пустоту. А сам стор — это клиентское состояние, а не кэш сервера: в нём токен, профиль и права, но не список заказов пользователя.

Защита роутов: guard, а не v-if

Прятать приватный экран через v-if="isAuth" — не защита, а косметика: компонент уже загружен, запросы уже ушли. Нужен guard на маршруте.

Инициализация до первого перехода

Самый частый баг здесь — не безопасность, а мигание. Пользователь жмёт F5 на приватной странице, guard срабатывает раньше, чем приложение успело восстановить сессию по refresh, и человека выбрасывает на логин. Через полсекунды сессия восстанавливается, и его возвращает обратно.

Лечится тем, что guard ждёт готовность:

router.beforeEach(async (to) => {
  await auth.ready;                        // промис, который резолвится один раз после старта

  if (to.meta.requiresAuth && auth.session.status !== 'authenticated') {
    return { name: 'login', query: { redirect: to.fullPath } };
  }
});

redirect в query — мелочь, которую замечают все. Без неё после логина человек попадает на главную и идёт искать страницу, где был.

Роли и права: фронт — это UX, бэк — это безопасность

Скрытая кнопка не защищает эндпоинт. Любой, кто открыл DevTools, отправит запрос сам. Поэтому разделение жёсткое: на фронте права определяют, что показывать, на бэке — что разрешать.

Это, кстати, пункт моего чек-листа на ревью: проверяются ли права не только на фронте. Формулировка «мы же не показываем эту кнопку менеджерам» на ревью не принимается.

Когда сессия истекла посреди работы

Отдельный сюжет — не потерять то, что человек уже набрал. Если на экране форма на сорок полей, разлогин с редиректом уничтожает полчаса работы. Модалка «сессия истекла, войдите заново» поверх страницы плюс черновик формы в хранилище решают это малой кровью.

Логаут, который действительно разлогинивает

Кнопка «выйти», которая просто чистит localStorage и редиректит на /login, создаёт опасную иллюзию. Сессия на провайдере жива, refresh-токен не отозван, а в соседней вкладке приложение продолжает работать.

Что чистить локально

Токен в памяти, стор, кэш запросов и — если пользуетесь чем-то вроде TanStack Query — весь queryClient. Данные предыдущего пользователя, оставшиеся в кэше после входа под другим аккаунтом, это классический инцидент на общих рабочих машинах.

async function logout() {
  await authApi.revoke();          // отзыв refresh на сервере
  queryClient.clear();             // чужих данных в кэше не остаётся
  channel.postMessage({ type: 'logout' });
  hardReset();
}

RP-initiated logout и грабля Keycloak 18

Локальной чистки мало: нужно завершить сессию у провайдера, иначе следующий вход пройдёт молча и человек не поймёт, что вообще вышел.

Тут ждёт ломающее изменение, на которое до сих пор наступают. В Keycloak 18 параметр redirect_uri у endpoint логаута объявлен устаревшим, вместо него нужны id_token_hint и post_logout_redirect_uri. Без id_token_hint пользователь увидит экран подтверждения выхода — а если приложение продолжает слать старый redirect_uri, логаут просто отвалится с ошибкой про недостающие параметры. Есть режим совместимости, но это отсрочка, а не решение: id_token стоит сохранять при входе именно ради этого шага.

Back-channel logout

Front-channel завязан на браузер пользователя: провайдер по очереди дёргает приложения через скрытые iframe. Ненадёжно ровно по той же причине, что и silent SSO ниже, — сторонние куки.

Back-channel идёт сервер-серверу: провайдер сам отправляет каждому приложению logout-токен, и приложение гасит сессию у себя. Это то, что нужно, когда админ отзывает доступ уволенному сотруднику, — ждать, пока тот откроет браузер, никто не будет. Для фронта это означает, что сессия может кончиться без единого действия пользователя, и приложение должно уметь это обработать, а не показать бесконечный спиннер.

Когда приходит SSO и всё меняется

Пока продукт один, всё вышеописанное — ваша забота. Как только продуктов три и заказчик хочет единый вход, появляется провайдер: Keycloak, Authentik или облачный.

Authorization Code с PKCE, и только

Меняется главное — приложение перестаёт видеть пароль. Вместо формы логина редирект на провайдера, обратно приезжает код, код меняется на токены.

Implicit flow в RFC 10017 помечен как «никогда»: он отдавал токен прямо в URL, откуда тот попадал в историю браузера, логи прокси и заголовок Referer. Если в статье или в чужом коде видишь response_type=token — это устаревший материал целиком, дальше можно не читать.

Что при этом теряет фронтенд

Следствие неприятное: своей формы логина у вас больше нет, а с ней ушёл контроль над её вёрсткой и текстами ошибок. Тему провайдера обычно можно кастомизировать, но это не ваш макет из Figma, и проговорить это с дизайнером лучше до начала работ, а не на демо.

И про серверный рендер: состояние должно жить на запрос, а не на модуль. Синглтон с токеном на сервере утечёт между пользователями, и заметят это под нагрузкой.

Keycloak: что ломается прямо сейчас

Самое неочевидное в 2026-м — silent check-sso работает не у всех. Не «сломан», а именно выборочно, что заметно хуже для отладки.

Silent check-sso и сторонние куки

keycloak-js умеет молча проверять наличие активной сессии: грузит скрытый iframe на домен Keycloak. Работает это ровно до тех пор, пока браузер прикладывает куку Keycloak в кросс-доменном контексте, то есть пока разрешены сторонние куки. Safari и Firefox блокируют их по умолчанию; Chrome после разворота 2024 года оставил включёнными, но с пользовательским тумблером.

В цифрах это около 18–19% трафика на десктопе и почти 25% на мобильных — последнее практически целиком мобильный Safari. То есть каждый четвёртый пользователь с телефона увидит «вы не авторизованы» там, где сессия на самом деле есть. Тот же механизм ломает check-session iframe из OIDC Session Management, а он умеет разлогинивать ложно.

Что с этим делать сегодня

  • не считать silentCheckSsoRedirectUri источником истины;
  • выключить checkLoginIframe, чтобы он не выбрасывал живые сессии;
  • определять состояние по сроку жизни токена и обычному редиректу на провайдера;
  • сохранять id_token при входе — он понадобится на логауте.

FedCM — браузерный API, придуманный ровно под эту задачу, — выглядит правильным долгим решением, но поддержка в Keycloak пока не доведена.

Чек-лист

Пробежаться перед тем, как считать аутентификацию сделанной:

  • refresh-токен недоступен из JavaScript;
  • access не лежит в localStorage и не попадает туда через сериализацию стора;
  • куки — Secure, SameSite, префикс __Host-;
  • пять параллельных 401 дают один refresh, а не пять;
  • повтор запроса после refresh ограничен одной попыткой;
  • exp сравнивается в миллисекундах и с запасом на расхождение часов;
  • вкладки синхронизируют и вход, и выход;
  • guard ждёт восстановления сессии и помнит, куда возвращать;
  • права проверяются на бэкенде, а не только скрытием кнопки;
  • логаут отзывает токен на сервере и чистит кэш запросов;
  • истечение сессии не уничтожает заполненную форму;
  • в бандле нет ни одного секрета.

Логику из этого списка стоит закрыть тестами: isExpired с граничными значениями, guard на всех состояниях сессии, refresh под несколькими параллельными запросами. Это чистые функции и предсказуемые сценарии — самые дешёвые тесты из возможных, а цена ошибки тут выше средней по проекту.

Аутентификация — редкий случай, когда «сделали по туториалу» и «сделали правильно» расходятся почти сразу. Проверка, которая вскрывает большую часть описанного за пять минут: открой приложение в Safari, залогинься, дождись истечения access, поработай в пяти вкладках и нажми «выйти» в одной из них.

Ссылки