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

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

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

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

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

Ось 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, а для опознания пользователя на стороне клиента. Правильно настроенный бэкенд такой токен отвергнет (например, потому что у id_token другая аудитория — он выписан для клиента, а не для вашего API). В заголовок Authorization идёт access_token и только он.

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, на его /token, и больше никуда. В ваш API он не попадает никогда — вашему бэкенду refresh_token не нужен и видеть он его не должен. Это «секретный талон» между клиентом и Keycloak.

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

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

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

diagram

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

  • 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 хранит у себя.

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

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

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

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

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

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

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

diagram

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

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

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

Режим JWT — локальная проверка по подписи (то, что нужно в большинстве случаев):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/shop

По issuer-uri Spring сам найдёт JWKS-адрес (через .well-known/openid-configuration), скачает ключи и будет проверять каждый токен локально: подпись, срок и совпадение издателя. Если адрес ключей хочется задать напрямую (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 предназначен клиенту, чтобы показать, кто вошёл, и правильно настроенный бэкенд его отвергнет.
  • Хранить refresh_token (и любые токены) в localStorage. В браузерном localStorage токен доступен любому скрипту на странице. Одна XSS-уязвимость (чужой скрипт на странице) — и токен утёк, а refresh_token особенно опасен: им выписывают новые access-токены. Безопаснее — httpOnly-cookie (скрипту не виден) или хранение на стороне бэкенда.
  • Путать формат проверки. Настроили блок jwt, а Keycloak отдаёт opaque-токены (или наоборот) — проверка не пройдёт. Сначала определитесь, какой формат у access_token, и настройте соответствующий режим. Включить оба блока сразу нельзя.
  • Не проверять издателя (iss) и аудиторию (aud). Даже у токена с верной подписью надо убедиться, что его выпустил именно ваш realm (iss) и что он предназначен именно вашему API (aud). Иначе валидный токен от соседнего сервиса пройдёт там, где не должен. В режиме jwt Spring проверяет iss сам; проверку aud обычно добавляют отдельно.
  • Расхождение часов и протухание (clock skew). Токен действует ограниченное время. Если часы на сервере Keycloak и на бэкенде разъехались, свежий токен может выглядеть «ещё не наступившим» или «уже просроченным», и пойдут необъяснимые 401. Лечится синхронизацией времени (NTP) и небольшим допуском по времени при проверке.
  • Слишком длинный срок жизни 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. Без него cookie живёт до закрытия вкладки (session cookie); с явным сроком вы контролируете, сколько refresh_token вообще действует на стороне браузера.

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

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

Коротко

  • Keycloak отдаёт три токена — и у каждого своя роль и свой адресат, путать нельзя.
  • access_token → в ваш API, в заголовке Authorization: Bearer. Единственный токен, который API видит.
  • id_token → клиенту, чтобы показать, кто залогинился (sub, name, email). В API не шлётся никогда.
  • refresh_token → только обратно в Keycloak на /token за новым access. В API не попадает; хранить безопасно (httpOnly-cookie / на бэкенде), не в localStorage.
  • Заголовок Authorization один — и в нём всегда access_token.
  • JWT vs opaque — это формат самого access_token, а не ещё один токен. JWT проверяется локально по ключам из JWKS (offline, быстро). Opaque проверяется через introspection в Keycloak (online, на каждый запрос, зато мгновенный отзыв).
  • В Spring это взаимоисключающие режимы: jwtissuer-uri) или opaquetokenintrospection-uri + client). Один из двух, не оба.
  • Частые ошибки: id_token в API; refresh в localStorage; не проверять iss/aud; расхождение часов; перепутать режим проверки opaque и JWT.

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

  • OAuth2 и OIDC простыми словами — откуда вообще берутся три токена и чем доступ отличается от опознания личности.
  • Authorization Code Flow и PKCE — как именно клиент получает эти токены: редирект, код, обмен кода на токены.
  • Keycloak и Spring Security: проверка токенов — настройка Resource Server, issuer-uri, чтение claims из токена.
  • Роли и доступ: RBAC и ABAC с Keycloak — как роли из access_token превращаются в проверки доступа.