← назад к разделу

Вы залогинились через Keycloak, получили в ответ JSON с тремя длинными строками — access_token, id_token, refresh_token — и тут начинается путаница. Какую из них класть в запрос к своему API? Можно ли отправить туда id_token? А refresh_token куда девать? И вдобавок где-то пишут про «opaque-токены» и «introspection» — это что, четвёртый токен? Сейчас разложим всё по полкам так, чтобы вы больше никогда не перепутали.

Самое важное, что нужно усвоить с самого начала: здесь две разные оси, и их постоянно смешивают.

  • Ось 1 — роль токена. Это про то, зачем токен нужен и кому он адресован. Три токена — три роли. Каждый летит в своё место.
  • Ось 2 — формат access-токена. Это про то, как выглядит именно access_token внутри и как его проверяют. Здесь два варианта — JWT и opaque. Это не ещё один токен, а две формы одного и того же access_token.

Если эти оси не развести, получается каша вида «у меня четыре токена и я не понимаю, какой опять не подходит». Разведём.

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

после входа: три токена — и у каждого свой адрес клиент · браузер access_token id_token показать, кто вошёл refresh_token httpOnly-cookie · 1800 с в памяти · 300 с истёк · нужен новый Authorization: Bearer один слот на три токена access_token id_token refresh сюда не кладут ваш API проверяет один заголовок 200 OKподпись сошлась, aud = ваш API 401 — если проверяете audaud = web-app, а не ваш API refresh сюда не приходит Keycloak · /token выдал все три не участвует новый accessснова на 300 с access — 300 с, refresh — 1800 с, id — только клиенту в Authorization: Bearer идёт access_token — в ответ 200 тот же заголовок, но id_token — 401, если проверяется aud refresh идёт в Keycloak /token, а не в API

Заголовок Authorization один, и в нём всегда access_token: id_token API отвергнет по aud — но только если вы эту проверку включили, сама собой она не работает. А refresh_token в API не попадает вовсе — через 300 секунд он уходит в Keycloak на /token за новым access.

Обязательно

Ось 1: три токена — три роли

Когда клиент (фронтенд или мобильное приложение) меняет код авторизации на токены, Keycloak отвечает примерно таким JSON:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI...",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "scope": "openid profile email"
}

Три токена — и у каждого своя работа, своё место назначения. Перепутать их — самая частая ошибка новичка. Разберём по одному.

access_token — пропуск к вашему API

access_token — это «пропуск к данным». Он отвечает на вопрос «что этому запросу можно делать». Именно его — и только его — клиент прикладывает к каждому запросу к вашему бэкенду, в заголовке:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI...

Запомните накрепко: access_token — единственный токен, который вообще видит ваш API. Ваш бэкенд не знает и не должен знать про id_token и refresh_token. Для него весь мир токенов — это одна строка в заголовке Authorization: Bearer ..., и в ней всегда лежит access_token.

Слово Bearer означает «предъявитель»: кто принёс токен — тот и считается владельцем. Поэтому к хранению токенов относятся серьёзно (об этом в разделе про ошибки). Живёт access_token недолго — обычно несколько минут (expires_in выше — 300 секунд, то есть 5 минут).

id_token — кто залогинился, для самого клиента

id_token отвечает на другой вопрос — «кто этот пользователь». Внутри него лежат данные о человеке, который вошёл: его идентификатор, имя, e-mail. Эти данные называют claims (утверждения). Типичный набор:

  • sub — неизменный идентификатор пользователя;
  • name — отображаемое имя;
  • email — почта;
  • preferred_username — логин.

Кому это нужно? Самому клиенту — фронтенду или мобильному приложению. Чтобы написать в углу страницы «Привет, Иван» и показать аватарку, приложению нужно знать, кто вошёл. Вот для этого id_token и существует: это «удостоверение личности» для того, кто запросил вход.

И тут — главное правило, ради которого многие сюда и пришли:

id_token в API не шлётся. Никогда.

Очень частая ошибка новичка: получил два похожих с виду токена, взял первый попавшийся (нередко именно id_token) и положил его в Authorization: Bearer .... Внешне строки похожи, оба — длинный JWT. Но id_token придуман не для доступа к API, а для опознания пользователя на стороне клиента. В заголовок Authorization идёт access_token и только он.

И тут стоит сказать неприятную правду, а не успокоительную. «Правильно настроенный бэкенд id_token отвергнет» — да, отвергнет: у id_token другая аудитория (aud), он выписан для клиента, а не для вашего API. Но проверка аудитории не включается сама. Настройка «по умолчанию», которой учат почти все руководства (один issuer-uri и всё), проверяет подпись, срок и издателя — а aud не смотрит вовсе. У id_token из вашего же realm подпись настоящая, издатель ваш, срок не вышел, — и сервис его спокойно примет и ответит 200. Так что «правильно настроенный» здесь означает «с добавленной проверкой аудитории»; как её добавить — в статье про проверку токенов. Без неё это не защита, а дыра, про которую вы думаете, что её нет.

refresh_token — талон на продление, только для Keycloak

access_token живёт пять минут. Что, пользователю каждые пять минут заново вводить пароль? Конечно, нет. Для этого есть refresh_token — «талон на продление».

Когда access_token протух, клиент не идёт к пользователю за паролем, а отправляет refresh_token обратно в Keycloak, на token-адрес, и получает свежий access_token — а вместе с ним и новый refresh_token:

POST https://keycloak.example.com/realms/shop/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&client_id=web-app
&refresh_token=<сохранённый refresh_token>

Новый refresh_token приходит, но не думайте, что старый после этого умер. По умолчанию в Keycloak он продолжает работать до своего срока — то есть после обновления у вас на руках два живых талона, и утёкший старый никуда не делся. Чтобы старый гасился при каждом обновлении, в настройках realm включают Revoke Refresh Token; из коробки эта галочка снята.

Ключевое про адресата: refresh_token летит только в Keycloak, на его /token, и больше никуда. В ваш API он не попадает никогда — вашему бэкенду refresh_token не нужен и видеть он его не должен. Это «секретный талон» между клиентом и Keycloak.

Поскольку refresh_token живёт долго (часы или дни) и им можно получать новые access-токены, его кража опаснее. Поэтому хранить его надо аккуратно: в httpOnly-cookie (скрипту на странице не виден) или вообще на стороне бэкенда — но не в localStorage браузера, откуда его утащит первый же чужой скрипт. К этой ошибке ещё вернёмся.

Все три на одной схеме

Что на схеме: каждый из трёх токенов после входа летит в своё место — и эти места не пересекаются.

Диаграмма

Прочитайте схему как ответ на исходную путаницу:

  • access_token идёт в ваш API (Authorization: Bearer). Единственный токен, который API видит.
  • id_token остаётся у клиента и используется, чтобы показать, кто вошёл. В API не уходит.
  • refresh_token уходит обратно в Keycloak на /token, чтобы получить новый access. В API не уходит.

Если коротко свести всё к одной фразе: заголовок Authorization один — и в нём всегда access_token; id_token и refresh_token туда не кладут никогда.

Ось 2: каким бывает сам access_token — JWT или opaque

Теперь, когда роли разведены, посмотрим внутрь именно access_token. Вот тут и появляется «opaque vs JWT». Подчеркнём ещё раз, чтобы снять путаницу: это не четвёртый токен и не пятый. Это два возможных формата одного и того же access_token. В заголовок Authorization: Bearer ... в обоих случаях идёт access_token — меняется лишь то, как он устроен внутри и как бэкенд его проверяет.

Формат JWT (by-value): самодостаточный, проверяется локально

По умолчанию Keycloak выдаёт access_token в формате JWT (JSON Web Token). Это «самодостаточный» токен: внутри него уже лежат все нужные данные (кто пользователь, какие роли, до какого времени действует), а сверху стоит криптографическая подпись Keycloak.

«By-value» значит «по значению»: всё нужное — в самом токене. Бэкенду не надо никуда ходить, чтобы узнать, кто это, — он просто читает содержимое токена и проверяет подпись.

Проверка подписи устроена так. Keycloak подписывает токен своим приватным ключом, который знает только он. А публичный ключ для проверки раздаёт всем по специальному адресу — JWKS (JSON Web Key Set):

https://keycloak.example.com/realms/shop/protocol/openid-connect/certs

Дальше бэкенд:

  1. один раз скачивает публичные ключи с JWKS-адреса и держит их в памяти;
  2. на каждый входящий запрос берёт подпись из токена и проверяет её этим ключом — локально, без обращения в Keycloak;
  3. подпись сошлась — токен настоящий; не сошлась — запрос отклоняется.

Главный плюс: на каждый запрос никакого сетевого вызова в Keycloak нет — ключи уже в памяти, проверка мгновенная и не зависит от того, жив ли Keycloak прямо сейчас. Это та самая «offline-проверка».

Минус — обратная сторона того же: раз бэкенд проверяет всё сам и в Keycloak не ходит, он и не узнает мгновенно, что токен отозвали. Отозванный JWT доработает до своего срока истечения (exp). Поэтому access_token делают коротким — окно «токен отозван, но ещё работает» получается маленьким.

Формат opaque (by-reference): случайная строка, проверяется в Keycloak

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

Сразу оговоримся, чтобы вы не искали переключатель в админке: Keycloak в этом формате access_token не выдаёт. Он всегда отдаёт JWT. Opaque-строки отдают другие серверы аутентификации, и разбираем мы этот формат потому, что Spring его умеет и вы наверняка встретите его в чужом проекте. А спросить Keycloak по сети «жив ли этот токен» можно и про JWT — это отдельное решение поверх того же самого JWT, о нём ниже.

«By-reference» значит «по ссылке»: в токене нет данных, есть только указатель. Чтобы узнать, кто это и действителен ли токен, бэкенд обязан спросить у сервера аутентификации — обратиться к introspection-адресу:

POST https://keycloak.example.com/realms/shop/protocol/openid-connect/token/introspect

В ответ сервер аутентификации говорит: токен активен или нет, чей он, какие у него роли.

Плюс: отзыв работает мгновенно — как только сессию завершили, следующая же проверка вернёт «недействителен». Никакого окна, как у JWT.

Минус: за это платят сетевым вызовом на каждый запрос к API (на практике результаты обычно кешируют на короткое время, но всё равно это поход по сети, и при недоступности сервера аутентификации проверка встаёт).

Две ветки проверки на одной схеме

Что на схеме: бэкенд получил access_token и дальше валидирует его по-разному в зависимости от формата.

Диаграмма

Коротко про выбор: JWT — быстро и без зависимости от сервера аутентификации в рантайме, но отзыв с задержкой до exp. Opaque — мгновенный отзыв, но сетевой вызов на каждый запрос. С Keycloak выбора, строго говоря, и нет: он выдаёт только JWT — и для большинства сервисов этого достаточно.

Как это настроить в Spring

В Spring всю проверку берёт на себя стартер spring-boot-starter-oauth2-resource-server — распаковывать токены руками не нужно и не стоит (самописный фильтр легко сделать дырявым). Важно: два формата — две разные настройки, и они взаимоисключающие. Вы выбираете одно из двух, не оба сразу.

Режим JWT — локальная проверка по подписи, то, что нужно в большинстве случаев: блок jwt с issuer-uri, разобранный в статье про интеграцию со Spring Security. Если адрес ключей хочется задать напрямую (Keycloak за прокси, нестандартный путь), вместо issuer-uri указывают jwk-set-uri.

Режим opaque — проверка через introspection (когда нужен мгновенный отзыв):

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://keycloak.example.com/realms/shop/protocol/openid-connect/token/introspect
          client-id: my-api
          client-secret: ${INTROSPECTION_CLIENT_SECRET}

Здесь Spring на каждый запрос дёргает introspection-адрес, представляясь своими client-id/client-secret. Заметьте: в блоке jwt нет никаких credentials (проверка локальная и анонимная), а в блоке opaquetoken они обязательны (надо же чем-то авторизоваться при обращении в Keycloak). Это и есть наглядная разница двух осей: токен один — access_token, а способов его проверить два.

Частые ошибки и как их избежать

Большинство проблем с токенами — не «взлом криптографии», а путаница в ролях и недосмотры в настройках.

  • Слать id_token в API. Самая частая ошибка из-за внешнего сходства токенов. В Authorization: Bearer ... идёт только access_token. id_token предназначен клиенту, чтобы показать, кто вошёл. Бэкенд с проверкой аудитории его отвергнет — но именно с проверкой: без неё id_token своего же realm спокойно пройдёт.
  • Хранить refresh_token (и любые токены) в localStorage. В браузерном localStorage токен доступен любому скрипту на странице. Одна XSS-уязвимость (чужой скрипт на странице) — и токен утёк, а refresh_token особенно опасен: им выписывают новые access-токены. Безопаснее — httpOnly-cookie (скрипту не виден) или хранение на стороне бэкенда.
  • Путать формат проверки. Keycloak всегда выдаёт access_token в формате JWT, поэтому в Spring настраивают блок jwt. Блок opaquetoken — для серверов аутентификации, которые отдают непрозрачную строку и требуют спрашивать о ней по сети; включить оба сразу нельзя. Спрашивать сервер о токене можно и при JWT — например, чтобы узнать, не отозван ли он, — но это отдельное решение, а не другой формат.
  • Не проверять издателя (iss) и аудиторию (aud). Даже у токена с верной подписью надо убедиться, что его выпустил именно ваш realm (iss) и что он предназначен именно вашему API (aud). Иначе валидный токен от соседнего сервиса пройдёт там, где не должен. В режиме jwt Spring проверяет iss сам, а aud не проверяет вовсе, пока вы не перечислите свои аудитории в настройке audiences.
  • Расхождение часов и протухание (clock skew). Токен действует ограниченное время. Если часы на сервере Keycloak и на бэкенде разъехались, свежий токен может выглядеть «ещё не наступившим» или «уже просроченным», и пойдут необъяснимые 401. Лечится синхронизацией времени (NTP) и небольшим допуском по времени при проверке. Такой допуск в Spring уже есть и равен 60 секундам: JwtTimestampValidator по умолчанию прощает минуту расхождения в обе стороны. Менять его приходится редко, и делается это сборкой декодера руками — NimbusJwtDecoder.withJwkSetUri(...) плюс свой набор проверок с new JwtTimestampValidator(Duration.ofSeconds(30)). Если расхождение больше минуты, чинят часы, а не допуск: сдвинутое время ломает не только токены.
  • Слишком длинный срок жизни access_token. Соблазнительно поставить сутки, чтобы реже обновлять. Но в формате JWT отозвать токен до истечения нельзя — украденный проживёт сутки. access_token держат коротким, а удобство дают через refresh_token (а где нужен мгновенный отзыв — берут opaque).

Выше мы сказали: refresh_token безопаснее хранить в httpOnly-cookie, чем в localStorage. Но «положить в cookie» — это не одна галочка, а несколько атрибутов, и каждый закрывает свою дыру. Если выставить cookie кое-как, она защищает не лучше localStorage. Разберём, что именно делает каждый атрибут и почему он там нужен.

Cookie, в которой лежит refresh_token (или серверная сессия), бэкенд выставляет примерно так:

var refreshCookie = ResponseCookie.from("refresh_token", refreshToken)
    .httpOnly(true)
    .secure(true)
    .sameSite("Lax")
    .path("/auth/refresh")
    .maxAge(Duration.ofDays(7))
    .build();

И вот что даёт каждая строчка:

АтрибутЗачем нужен
HttpOnlyСкрипт на странице не может прочитать cookie — document.cookie её просто не вернёт. Это и есть та самая защита от XSS, ради которой мы ушли от localStorage: даже если на страницу попал чужой скрипт, до токена он не доберётся.
SecureCookie уходит только по HTTPS. Без этого атрибута браузер отправит её и по обычному http, и токен можно перехватить в открытом виде в сети (например, в публичном Wi-Fi).
SameSite=LaxБраузер не приложит cookie к запросу, который инициировал чужой сайт через форму/POST. Это защита от CSRF: вредоносная страница не сможет «от вашего имени» дёрнуть refresh, потому что cookie к её запросу не приедет.
Path=/auth/refreshCookie прикладывается только к запросам на этот путь, а не ко всем подряд.
Max-Age (или Expires)Срок жизни cookie. Без него получается session cookie — она живёт до закрытия всего браузера, а не вкладки, да ещё и переживает его: браузеры умеют восстанавливать прошлую сессию вместе с такими cookie. С явным сроком вы контролируете, сколько refresh_token вообще действует на стороне браузера.

Отдельно про Path — это нюанс, который часто упускают. Если оставить путь по умолчанию (/), то cookie с refresh_token браузер будет прикладывать к каждому запросу к вашему домену — в том числе к обычным вызовам API, которым refresh_token вообще не нужен. Чем чаще длинноживущий и опасный токен «светится» по сети, тем больше шансов, что он где-то осядет — в логах прокси, в отладочной панели, в случайном дампе запроса. Сузив путь до узкого /auth/refresh, вы добиваетесь того, что refresh_token уходит из браузера только тогда, когда реально нужно обновить access — то есть на один-единственный адрес обновления, и больше никуда.

Здравый принцип за всем этим простой: чем опаснее токен, тем уже должно быть место, где он появляется. access_token живёт пять минут и нужен на каждом запросе — он и так в заголовке. refresh_token живёт днями и опасен при краже — поэтому его прячут (HttpOnly), пускают только по защищённому каналу (Secure), не дают увести чужому сайту (SameSite) и показывают браузеру лишь на одном узком пути (Path).

Где какие токены живут в маркетплейсе

У маркетплейса четыре разных входа: витрина в браузере, кабинет продавца, мобильное приложение и backoffice для сотрудников. Правила хранения токенов у них разные, и это не прихоть — разная поверхность атаки.

ПриложениеГде access_tokenГде refresh_tokenПочему так
Витрина (браузер)в памяти вкладкиhttpOnly-cookie на /auth/refreshв браузере всегда есть риск чужого скрипта на странице
Кабинет продавцав памяти вкладкиhttpOnly-cookieто же самое, и цена утечки выше: чужие товары и выплаты
Мобильное приложениев памяти процессаKeychain (iOS) / Keystore (Android)системное хранилище, а не файл рядом с приложением
Backofficeна серверена сервереприложение серверное, браузеру токены отдавать незачем

Оговорка к первым двум строкам, без которой таблица вводит в заблуждение. httpOnly-cookie кто-то должен поставить, а поставить её умеет только сервер. То есть за витриной и кабинетом уже стоит ваш бэкенд — просто тонкий: он проводит вход, держит refresh и отдаёт свежий access, а в остальное не лезет. А раз сервер всё равно есть, крепче всего отдать ему оба токена целиком и в браузер не выпускать даже access — это и есть BFF из статьи про поток входа. Строки в таблице — компромисс для случая, когда фронтенд уже написан так, что ходит в API сам, и переписывать его целиком пока не за что.

Срок жизни тоже стоит разный. Для витрины пять минут на access и неделя на refresh — нормальный компромисс: покупателя не выкидывает, а украденный access протухает быстро. Для backoffice берут короче и то и другое: у сотрудника права широкие, а работает он с рабочего места, где повторный вход не проблема.

Отдельный вопрос — выход из системы. Покупатель нажал «Выйти», витрина стёрла токен из памяти. Но выданный access_token в формате JWT продолжает действовать до истечения: сервисы проверяют его локально, ни у кого не спрашивая. Значит, «выйти» в маркетплейсе — это два действия: убрать токены у клиента и отозвать сессию в Keycloak (end_session_endpoint), чтобы refresh перестал работать. Иначе на чужом устройстве вкладку можно вернуть к жизни.

И место, где это чаще всего ломается: заблокированный продавец. Финансовый отдел заблокировал аккаунт — а токен, выданный десять минут назад, ещё живёт, и продавец успевает что-то сделать. Ровно поэтому access держат коротким: пять минут задержки терпимы, час — уже нет. Там, где нужен мгновенный отзыв (блокировка за мошенничество), одного JWT недостаточно — проверку блокировки делают в самом сервисе, по своим данным.

Размер токена: почему он однажды перестаёт проходить

У JWT есть свойство, о котором не думают, пока не случится отказ: он едет в заголовке каждого запроса, а заголовки ограничены по размеру. Простой токен Keycloak занимает 300–600 байт, но составные роли, группы, лишние поля в маппере и длинный список аудиторий легко доводят его до килобайтов.

Границы выглядят так. Tomcat по умолчанию принимает заголовки до 8 КБ (server.max-http-request-header-size), nginx — 8 КБ на буфер (large_client_header_buffers), у облачных балансировщиков нередко 8–16 КБ на все заголовки вместе. Превышение неприятно тем, как оно проявляется: запрос не доходит до приложения и получает 431, а у части прокси просто 400, без единого слова про токен. Отладка уходит по ложному следу — «ломается только у некоторых пользователей», — а ломается у тех, у кого ролей больше всех.

Раздувают токен почти всегда одни и те же вещи: составные роли, которые раскрываются в токен целиком; группы, добавленные маппером вместе со вложенными; поле со списком разрешений на каждый объект; aud с десятком клиентов. Лечится это не увеличением буфера, а чисткой: в токене оставляют роли, нужные именно этому API (client-роли вместо realm-ролей, где это возможно), группы не кладут, если по ним не проверяют доступ, а список разрешений по объектам не кладут никогда — это данные, а не личность, и читают их из базы.

Измерить просто: посмотреть длину access_token в ответе /token не у тестового пользователя, а у того, у кого набор ролей самый большой. Если токен перевалил за две-три тысячи байт и растёт вместе с числом клиентов, маппер чистят заранее, а не после первого 431 в проде.

Дополнительно: при первом чтении можно пропустить

Глубже: выход целиком: end_session, back-channel logout и отзыв refreshрасширенное

«Нажал выход, а через минуту всё ещё внутри» это не ошибка Keycloak, а три разных сессии, каждую из которых нужно закрыть отдельно, и статьи раздела упоминают их порознь.

Сессия первая: у приложения, токены на руках. Их стирают локально, и это единственное, что делает большинство кнопок «выйти». Сессия вторая: SSO-сессия в Keycloak, cookie в браузере на домене Keycloak. Пока она жива, следующий переход на вход проходит без пароля, и пользователь «всё ещё внутри». Закрывает её переход на end_session_endpoint: GET /realms/shop/protocol/openid-connect/logout?id_token_hint=<id_token>&post_logout_redirect_uri=https://shop.example.com/. Вот для чего приложению нужен id_token, который в API не уходит: он подтверждает Keycloak, чью сессию закрывать, а адрес возврата обязан быть в списке «Valid post logout redirect URIs» у client, иначе Keycloak покажет страницу с подтверждением вместо возврата.

Сессия третья: у остальных приложений с тем же SSO. Пользователь вышел из магазина, а вкладка с личным кабинетом продолжает работать своими токенами. Закрывает их back-channel logout: у каждого client указан «Backchannel logout URL», и при завершении SSO-сессии Keycloak сам отправляет туда logout_token (JWT с sid сессии), а приложение по нему закрывает свою серверную сессию. Работает для приложений с сессией на сервере (BFF, серверный рендеринг); чистому SPA слать некуда, и он узнаёт о выходе при следующей неудачной попытке обновить токен.

Отзыв refresh_token отдельно от выхода: POST /realms/shop/protocol/openid-connect/revoke с token=<refresh_token>&client_id=..., после чего им нельзя получить новые access. Настройка «Revoke Refresh Token» в Realm settings, вкладка Tokens, включает ротацию с инвалидацией: старый refresh после обновления перестаёт работать, а его повторное использование закрывает всю сессию как признак кражи; «Refresh Token Max Reuse» даёт небольшой допуск на гонки, о которых следующий раздел.

Чего выход не делает: уже выданные access_token остаются валидны до exp, потому что сервисы проверяют подпись локально и к Keycloak не ходят. Отсюда короткий срок жизни access и, где нужно мгновенно, проверка через introspection или список отозванных сессий. Полный выход это четыре действия: стереть токены локально, перейти на end_session с id_token_hint, принять back-channel logout в приложениях с сессией и держать access коротким.

Глубже: обновление токена на практике: гонка вкладок, ротация и offline-токенырасширенное

Схема «access протух, отправили refresh, получили новые» проста в одном потоке. У пользователя пять вкладок и на странице десять параллельных запросов, и все они получают 401 одновременно.

Гонка. Десять запросов видят 401 и десять раз идут на /token с одним и тем же refresh_token. Без ротации это лишь десять лишних запросов. С включённой ротацией первый обмен успевает, старый refresh отзывается, а девять остальных получают invalid_grant: Maximum allowed refresh token reuse exceeded, и Keycloak закрывает сессию целиком, потому что повторное использование refresh это признак кражи. Пользователь выкинут из всех вкладок сразу после часа работы, и это классическая жалоба «само разлогинивает».

Очередь обновления. Лечится на клиенте: обновление делается одно, остальные ждут его результата. В одной вкладке это единственный «промис» обновления, к которому присоединяются все запросы, получившие 401, и только после его завершения повторяются с новым токеном. Между вкладками нужна блокировка: navigator.locks или флаг в localStorage с BroadcastChannel, чтобы одна вкладка обновляла, а остальные забирали результат. Библиотеки OIDC для браузера (oidc-client-ts, keycloak-js) это умеют, но только если запросы к API идут через их обёртку, а не мимо. На сервере, у BFF, то же самое: обновление под замком по идентификатору сессии. «Refresh Token Max Reuse» в единицу или двойку даёт допуск на неизбежные гонки, но не заменяет очередь.

Упреждающее обновление. Проще, чем ловить 401: обновлять за минуту до exp, пока запросов нет, по таймеру. Тогда гонка почти не возникает, а 401 остаётся редким путём для случая, когда вкладка спала.

Offline-токены. Обычный refresh живёт, пока жива SSO-сессия (по умолчанию простой 30 минут, максимум 10 часов), и фоновая задача от имени пользователя, например синхронизация календаря ночью, его не дождётся. Для этого просят scope offline_access: Keycloak выдаёт offline-токен, который не привязан к сессии браузера и живёт, пока им пользуются (простой 30 дней по умолчанию), а пользователь видит и отзывает его в своём аккаунте. Хранят его только на сервере, как ключ доступа, и просят только у приложений, которым правда нужно работать без пользователя; для обычного фронтенда это лишняя дыра.

Коротко

  • Keycloak отдаёт три токена, у каждого свой адресат: access_token едет в ваш API в заголовке Authorization: Bearer и это единственный токен, который API видит.
  • id_token → клиенту, чтобы показать, кто залогинился (sub, name, email). В API не шлётся никогда.
  • refresh_token → только обратно в Keycloak на /token за новым access. В API не попадает; хранить безопасно (httpOnly-cookie / на бэкенде), не в localStorage.
  • JWT vs opaque — это формат самого access_token, а не ещё один токен. JWT проверяется локально по ключам из JWKS (offline, быстро). Opaque проверяется через introspection (online, на каждый запрос, зато мгновенный отзыв) — но сам Keycloak opaque не выдаёт, он всегда отдаёт JWT.
  • В Spring это взаимоисключающие режимы: jwt (с issuer-uri) или opaquetoken (с introspection-uri + client). Один из двух, не оба.
  • Частые ошибки: id_token в API; refresh в localStorage; не проверять aud (по умолчанию Spring его и не проверяет); расхождение часов; перепутать режим проверки opaque и JWT.
  • Выход это четыре действия: стереть токены, end_session с id_token_hint и разрешённым адресом возврата, back-channel logout для приложений с сессией, короткий access; отзыв refresh через /revoke и ротация с инвалидацией в настройках realm.
  • Обновление под замком: одна очередь на вкладку и блокировка между вкладками, иначе ротация закрывает сессию при гонке; упреждающее обновление по таймеру; offline_access только для серверных задач без пользователя.
  • Токен едет в заголовке каждого запроса, а заголовки ограничены 8 КБ у Tomcat и nginx: раздутый ролями и группами токен отвечает 431 или 400 мимо приложения, лечится чисткой маппера, а не буфером.
  • Допуск на расхождение часов в Spring — 60 секунд по умолчанию; менять его нужно редко, а расхождение больше минуты чинят на часах, а не в декодере.

Что почитать дальше