Вы залогинились через Keycloak, получили в ответ JSON с тремя длинными строками — access_token, id_token, refresh_token — и тут начинается путаница. Какую из них класть в запрос к своему API? Можно ли отправить туда id_token? А refresh_token куда девать? И вдобавок где-то пишут про «opaque-токены» и «introspection» — это что, четвёртый токен? Сейчас разложим всё по полкам так, чтобы вы больше никогда не перепутали.
Самое важное, что нужно усвоить с самого начала: здесь две разные оси, и их постоянно смешивают.
- Ось 1 — роль токена. Это про то, зачем токен нужен и кому он адресован. Три токена — три роли. Каждый летит в своё место.
- Ось 2 — формат access-токена. Это про то, как выглядит именно access_token внутри и как его проверяют. Здесь два варианта — JWT и opaque. Это не ещё один токен, а две формы одного и того же access_token.
Если эти оси не развести, получается каша вида «у меня четыре токена и я не понимаю, какой опять не подходит». Разведём.
Прежде чем разбирать токены по одному — посмотрите, как они расходятся по своим адресам и что будет, если перепутать.
Заголовок 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
Дальше бэкенд:
- один раз скачивает публичные ключи с JWKS-адреса и держит их в памяти;
- на каждый входящий запрос берёт подпись из токена и проверяет её этим ключом — локально, без обращения в Keycloak;
- подпись сошлась — токен настоящий; не сошлась — запрос отклоняется.
Главный плюс: на каждый запрос никакого сетевого вызова в 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). Иначе валидный токен от соседнего сервиса пройдёт там, где не должен. В режимеjwtSpring проверяет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 в cookie — разберём атрибуты
Выше мы сказали: 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: даже если на страницу попал чужой скрипт, до токена он не доберётся. |
Secure | Cookie уходит только по HTTPS. Без этого атрибута браузер отправит её и по обычному http, и токен можно перехватить в открытом виде в сети (например, в публичном Wi-Fi). |
SameSite=Lax | Браузер не приложит cookie к запросу, который инициировал чужой сайт через форму/POST. Это защита от CSRF: вредоносная страница не сможет «от вашего имени» дёрнуть refresh, потому что cookie к её запросу не приедет. |
Path=/auth/refresh | Cookie прикладывается только к запросам на этот путь, а не ко всем подряд. |
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 секунд по умолчанию; менять его нужно редко, а расхождение больше минуты чинят на часах, а не в декодере.
Что почитать дальше
- OAuth2 и OIDC простыми словами — откуда вообще берутся три токена и чем доступ отличается от опознания личности.
- Authorization Code Flow и PKCE — как именно клиент получает эти токены: редирект, код, обмен кода на токены.
- Keycloak и Spring Security: проверка токенов — настройка Resource Server,
issuer-uri, чтение claims из токена. - Роли и доступ: RBAC и ABAC с Keycloak — как роли из access_token превращаются в проверки доступа.
- Кейс: маркетплейс — сквозной пример сайта: витрина, кабинет продавца, мобильное приложение и backoffice, для которых разобрано хранение токенов.