Вы залогинились через 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 браузера, откуда его утащит первый же чужой скрипт. К этой ошибке ещё вернёмся.
Все три на одной схеме
Что на схеме: каждый из трёх токенов после входа летит в своё место — и эти места не пересекаются.
Прочитайте схему как ответ на исходную путаницу:
- 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 хранит у себя.
«By-reference» значит «по ссылке»: в токене нет данных, есть только указатель. Чтобы узнать, кто это и действителен ли токен, бэкенд обязан спросить у Keycloak — обратиться к introspection-адресу:
POST https://keycloak.example.com/realms/shop/protocol/openid-connect/token/introspect
В ответ Keycloak говорит: токен активен или нет, чей он, какие у него роли.
Плюс: отзыв работает мгновенно — как только сессию завершили в Keycloak, следующая же проверка вернёт «недействителен». Никакого окна, как у JWT.
Минус: за это платят сетевым вызовом в Keycloak на каждый запрос к API (на практике результаты обычно кешируют на короткое время, но всё равно это поход по сети, и при недоступности Keycloak проверка встаёт).
Две ветки проверки на одной схеме
Что на схеме: бэкенд получил access_token и дальше валидирует его по-разному в зависимости от формата.
Коротко про выбор: 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). Иначе валидный токен от соседнего сервиса пройдёт там, где не должен. В режимеjwtSpring проверяетissсам; проверкуaudобычно добавляют отдельно. - Расхождение часов и протухание (clock skew). Токен действует ограниченное время. Если часы на сервере Keycloak и на бэкенде разъехались, свежий токен может выглядеть «ещё не наступившим» или «уже просроченным», и пойдут необъяснимые 401. Лечится синхронизацией времени (NTP) и небольшим допуском по времени при проверке.
- Слишком длинный срок жизни 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. Без него 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 это взаимоисключающие режимы:
jwt(сissuer-uri) илиopaquetoken(сintrospection-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 превращаются в проверки доступа.