К вашему сервису приходит HTTP-запрос с заголовком Authorization: Bearer <длинная строка>. Эту строку — access_token — выдал Keycloak после того, как пользователь вошёл в систему. И прежде чем сервис выполнит хоть одну строчку бизнес-логики, он обязан ответить на простой вопрос: этот токен настоящий, его правда выпустил наш Keycloak, и срок ещё не вышел? Если не проверить — кто угодно подставит любую строку и получит доступ к чужим данным.
Хорошая новость: Spring Boot умеет проверять такие токены почти без вашего кода. Плохая — пока не понимаешь, через какие руки проходит запрос внутри Spring, всё выглядит магией: написал три строки конфигурации, и вдруг одни запросы пускаются, а другие отлетают с ошибкой 401. Разберём эту «магию» по косточкам — кто и в каком порядке проверяет токен.
Зачем вообще проверять токен у себя, а не спрашивать Keycloak
Самый прямолинейный путь — на каждый входящий запрос звонить в Keycloak и спрашивать: «вот токен, он ещё годен?». Так действительно можно (этот приём называется introspection), но у него две беды. Первая — лишний сетевой вызов на каждый запрос: сервис становится медленнее, а Keycloak — узким местом под нагрузкой. Вторая — жёсткая зависимость: если Keycloak на минуту прилёг, ваш сервис перестаёт пускать вообще всех, даже с валидными токенами.
Поэтому современный access_token Keycloak делают в формате JWT (JSON Web Token). Это самодостаточный токен: внутри него уже лежат данные о пользователе и криптографическая подпись, поставленная закрытым (секретным) ключом Keycloak. Подпись можно проверить локально, имея на руках только парный открытый (публичный) ключ. Сервису не нужно никого спрашивать на каждый запрос — он один раз скачивает публичные ключи Keycloak и дальше сверяет подписи сам.
Аналогия: токен — как бумажный пропуск с водяным знаком. Охранник на входе не звонит в типографию по каждому посетителю — он один раз выучил, как выглядит правильный водяной знак, и сверяет на месте за секунду. Ровно так же Spring один раз запоминает «как выглядит подпись нашего Keycloak» и дальше проверяет токены самостоятельно.
Роль сервиса в этой схеме называется OAuth2 Resource Server — «сервер ресурсов». Запомните это разделение ролей: Keycloak логинит пользователей и выдаёт токены, а ваш сервис их только принимает и проверяет. Сам он токены не печатает и пользователей не аутентифицирует.
Чтобы и формат токена не путать с тем, как его проверяют: «JWT vs opaque» — это про формат access_token, а не про какой-то отдельный токен. JWT (by-value) несёт данные внутри себя, проверяется локально по публичному ключу. Opaque-токен (by-reference) — просто случайная строка-идентификатор, по которой данные приходится спрашивать у Keycloak через introspection. Keycloak по умолчанию выдаёт JWT, и эта статья — про него.
Где Keycloak хранит публичные ключи: JWKS
Чтобы проверить подпись локально, нужен публичный ключ Keycloak. Keycloak отдаёт свои публичные ключи по специальному адресу в виде набора ключей в формате JSON — это и называется JWKS (JSON Web Key Set). Для realm с именем myrealm адрес выглядит так:
https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
Здесь всплывают два ключевых термина Keycloak, которые важно не путать:
- realm — изолированное пространство со своими пользователями, ролями и ключами. У каждого realm — свой набор ключей в JWKS. Токен из одного realm не подойдёт к другому.
- client — приложение, зарегистрированное внутри realm. Frontend получает токен «от имени» какого-то client; ваш backend проверяет токены, выданные в рамках того же realm.
Spring один раз скачает JWKS, закеширует ключи в памяти и дальше будет сверять подпись каждого входящего токена с этими ключами. К Keycloak за ключами он ходит редко — только когда Keycloak ротирует (меняет) ключи и встречается неизвестная подпись, — а не на каждый запрос.
Шаг 1: одна зависимость
Всё, что нужно для роли Resource Server, упаковано в один стартер Spring Boot. Для Gradle:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
Для Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Этот стартер тянет за собой и Spring Security, и библиотеки для разбора и проверки JWT. Отдельный JWT-парсер подключать вручную не нужно — всё уже внутри.
Шаг 2: куда ходить за ключами
Дальше надо сказать Spring, у какого Keycloak и какого realm брать публичные ключи. Достаточно одной строки в application.yml. Есть два варианта — выберите один из двух.
Вариант А — issuer-uri (рекомендуется). Вы указываете адрес самого realm, а не ключей напрямую:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/myrealm
Что произойдёт при старте приложения: Spring сходит по адресу <issuer-uri>/.well-known/openid-configuration — это «визитка» realm, где Keycloak сам описывает все свои адреса. Оттуда Spring узнает точный адрес JWKS, скачает ключи и соберёт из них проверяльщик токенов — объект JwtDecoder (про него подробно ниже). Бонусом Spring запомнит, какой issuer (поле iss внутри токена) считается «своим», и будет автоматически отклонять токены, выпущенные чужим realm.
Вариант Б — jwk-set-uri. Вы напрямую даёте адрес ключей:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
Когда это нужно. Вариант А обращается к Keycloak уже при старте приложения — за той самой «визиткой». Если Keycloak в этот момент недоступен, ваш сервис не поднимется. Вариант Б на старте к Keycloak не ходит — ключи подтянутся при первом запросе с токеном. Платой за это будет потерянная автоматическая проверка iss (при желании её добавляют отдельно). На практике issuer-uri удобнее почти всегда — discovery и проверка издателя «из коробки» стоят дороже, чем независимость старта.
Шаг 3: что требует токена, а что открыто — SecurityFilterChain
Зависимость и адрес ключей научили сервис проверять токен. Но мы ещё не сказали, какие запросы вообще требуют токена. Health-check мониторинга должен отвечать без всякого токена, а вот защищённые данные — только по валидному. Это описывается бином SecurityFilterChain:
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
return http;
}
}
Читается сверху вниз, как список правил:
permitAll()— health-check и всё под/public/**доступны без токена;anyRequest().authenticated()— всё остальное требует валидный токен;oauth2ResourceServer(... .jwt(...))— главная строка: «токены, которые приходят, — это JWT, проверяй их так, как настроено вapplication.yml».
Импорт withDefaults — org.springframework.security.config.Customizer.withDefaults.
Здесь начинается то, ради чего статья. Слово Filter в названии бина — не случайное. Spring Security — это не один проверяльщик, а цепочка маленьких фильтров, через которые по очереди проходит каждый запрос, прежде чем добраться до вашего контроллера. Один из них умеет вытаскивать токен из заголовка Authorization, другой — проверять подпись, третий — раскладывать роли. Разберём этот конвейер пошагово.
Что происходит внутри: путь запроса по фильтрам
Когда строка .oauth2ResourceServer(... .jwt(...)) отработала при старте, Spring добавил в цепочку фильтр BearerTokenAuthenticationFilter. Это первое звено, которое касается токена. Его задача узкая: посмотреть в заголовок Authorization, и если там есть Bearer <токен> — вытащить эту строку. Если заголовка нет — фильтр просто пропускает запрос дальше, не падая (тогда позже сработает правило authenticated() и вернёт 401 для защищённых путей).
Дальше вытащенную строку нужно проверить. Этим занимается JwtDecoder — тот самый объект, который Spring собрал на шаге 2 из публичных ключей JWKS. Decoder делает несколько вещей за один проход, и все — локально, без обращения к Keycloak:
- сверяет подпись токена с публичным ключом из JWKS — это гарантия, что токен выпустил именно наш Keycloak и его никто не подменил;
- проверяет срок действия — поле
exp(истёк ли токен) иnbf(не «из будущего» ли он); - при варианте
issuer-uriпроверяет издателя — что полеissпринадлежит вашему realm, а не чужому.
Если декодер всё одобрил, у нас есть разобранный токен — объект Jwt с его содержимым (claims). Но Spring пока не знает, какие у пользователя права. Этим занимается JwtAuthenticationConverter — переходник, который смотрит на claims токена и превращает роли из них в понятные Spring «полномочия» (authorities). По умолчанию он ищет права не там, где их кладёт Keycloak, — об этом отдельный раздел ниже.
В конце фильтр складывает результат — объект JwtAuthenticationToken (внутри него лежит и сам Jwt, и список полномочий) — в SecurityContext. С этого момента пользователь считается аутентифицированным, и запрос идёт дальше, в ваш контроллер, где данные о пользователе уже доступны.
Вот этот путь целиком. Что на схеме: запрос проходит через фильтр, который достаёт Bearer-токен, затем токен проверяет JwtDecoder по ключам из Keycloak, права раскладывает converter, и только потом управление получает контроллер.
Ключевое, что стоит вынести: к моменту, когда ваш код в контроллере начинает работать, токен уже проверен — подпись сошлась, срок не истёк, издатель свой. Вам не нужно ничего проверять руками.
Что происходит при невалидном токене: ответ 401
Теперь обратная ветка — самая частая причина недоумения новичка: «почему мой запрос возвращает 401?». Невалидным токен может оказаться на любом из шагов. Например:
- заголовка
Authorizationвообще нет (забыли приложить токен); - токен просрочен — поле
expуже в прошлом; - подпись не сходится — токен подделан или выпущен чужим Keycloak;
- издатель чужой —
issне совпал с вашим realm.
Во всех этих случаях JwtDecoder бракует токен, фильтр не кладёт аутентификацию в SecurityContext, и сработавший обработчик (AuthenticationEntryPoint) возвращает клиенту 401 Unauthorized. Важнейшая деталь: это происходит до вашего контроллера — ваш код вообще не вызывается. Бизнес-логика защищена ещё на входе.
Не путайте два кода ответа. 401 Unauthorized — «я не знаю, кто ты»: токена нет или он невалиден. 403 Forbidden — «я знаю, кто ты, но прав не хватает»: токен валиден, пользователь аутентифицирован, но у него нет нужной роли для этого действия. 401 — про проверку токена (эта статья), 403 — про проверку прав.
Что на схеме: тот же путь, но JwtDecoder забраковал токен, и клиент получает 401, не доходя до контроллера.
Если в логах вы видите 401 на запросе, который должен был пройти, — почти всегда дело в одном из четырёх пунктов выше. Загляните в сам токен (его содержимое не зашифровано, его легко прочитать) и сверьте exp и iss.
Как достать пользователя и его данные из токена
Токен прошёл проверку — теперь логике нужны данные из него: кто это, какая у него почта. Содержимое токена называется claims (утверждения) — это просто пары «ключ-значение»: sub (идентификатор пользователя), preferred_username, email и так далее.
Самый простой способ добраться до них — попросить Spring передать разобранный токен прямо в метод контроллера:
@GetMapping("/me")
public String me(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject(); // claim "sub"
String username = jwt.getClaimAsString("preferred_username");
return "Привет, " + username + " (" + userId + ")";
}
Тот же токен доступен и через SecurityContext, если до него нужно добраться вне контроллера — например, в сервисном слое. Объект аутентификации здесь — JwtAuthenticationToken, тот самый, что фильтр положил в контекст:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Jwt jwt = ((JwtAuthenticationToken) auth).getToken();
String userId = jwt.getSubject();
Маленький, но важный совет: для идентификации пользователя берите claim sub, а не имя или почту. sub — это неизменный технический идентификатор, он не поменяется, даже если человек сменит логин или email. Привязывать данные к preferred_username — частая ошибка, которая аукается при первом же переименовании пользователя.
Подводный камень: роли Keycloak Spring по умолчанию не видит
Это самая частая причина «у меня всё валидно, но hasRole не работает». Дело в том, что Keycloak и Spring по-разному договорились, где в токене лежат роли.
Keycloak кладёт роли пользователя в claim realm_access.roles (роли уровня realm) и в resource_access (роли по конкретным client). А Spring Security по умолчанию ищет права совсем в другом месте — в claim scope. В итоге свежий проект ведёт себя странно: токен валиден, пользователь пускается через authenticated(), но любая проверка роли (hasRole("admin")) проваливается — Spring просто не нашёл, где Keycloak записал роли, и считает, что у пользователя их нет.
Здесь и выходит на сцену JwtAuthenticationConverter из схемы выше. По умолчанию он смотрит не туда; наша задача — объяснить ему, откуда у Keycloak брать роли:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
@SuppressWarnings("unchecked")
var roles = (List<String>) realmAccess.get("roles");
if (roles == null) {
return List.of();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.map(GrantedAuthority.class::cast)
.toList();
});
return converter;
}
Зачем префикс ROLE_. Это давнее соглашение Spring: когда вы пишете hasRole("admin"), под капотом Spring ищет полномочие с именем ROLE_admin. Поэтому, складывая роли из Keycloak, мы дописываем им этот префикс — иначе hasRole снова ничего не найдёт.
И последний шаг, о котором легко забыть. Раз SecurityFilterChain объявлен явно (как в шаге 3), конвертер тоже нужно передать в .jwt(...) явно — сам по себе бин не подхватится:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
После этого роли из Keycloak начинают работать в @PreAuthorize и в .hasRole(...).
Опасная настройка: как нечаянно превратить 401 в 403 и сломать refresh
По умолчанию Spring Security уже отвечает правильно: на невалидный или просроченный токен — 401 Unauthorized, на нехватку прав — 403 Forbidden. Ничего настраивать не нужно. Но есть распространённая ловушка: разработчик хочет «причесать» ответы об ошибках и переопределяет обработчики в блоке exceptionHandling — и нечаянно ломает поведение, которое работало само.
Вот как выглядит вредная настройка — отдавать 403 на всё подряд:
// ПЛОХО — теперь и невалидный токен отвечает 403
http.exceptionHandling(eh -> eh
.authenticationEntryPoint((req, resp, e) -> resp.setStatus(403))
.accessDeniedHandler((req, resp, e) -> resp.setStatus(403)));
Здесь authenticationEntryPoint — это как раз обработчик случая «токен не прошёл проверку» (нет заголовка, истёк exp, не сошлась подпись). Подменив его на ответ 403, вы сказали Spring: «на просроченный токен отвечай так, будто прав не хватает».
Почему это больно именно для просроченного токена. Access_token живёт недолго — минуты. Когда он истекает, грамотный клиент должен по своему refresh_token молча получить новый access_token и повторить запрос — пользователь даже не замечает. Но клиент принимает это решение по коду ответа: 401 он читает как «токен протух, надо обновиться», а 403 — как «токен в порядке, но это действие тебе запрещено, обновляться бесполезно». Подменив 401 на 403, вы лишаете клиент сигнала к обновлению. На истёкшем токене он получает 403, делает вывод «доступ запрещён», refresh не запускает — и пользователь оказывается в тупике: разлогинить себя клиент не считает нужным, а пускать его сервис не пускает.
Правильно — либо вообще ничего не трогать (дефолт Spring уже корректен), либо, если правка обработчиков всё-таки нужна, сохранить семантику кодов:
http.exceptionHandling(eh -> eh
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler((req, resp, e) -> resp.setStatus(HttpStatus.FORBIDDEN.value())));
HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) — это готовый обработчик Spring, который отвечает 401 на проблемы с аутентификацией. Так невалидный токен снова даёт 401 (клиент уйдёт обновляться), а нехватка прав — 403 (клиент покажет «доступ запрещён» и не будет зря дёргать refresh). Главное правило простое: не переопределяйте authenticationEntryPoint на 403. Если не уверены — не настраивайте exceptionHandling вовсе, дефолт делает ровно то, что нужно.
Коротко
- В этой схеме сервис — OAuth2 Resource Server: он не выдаёт токены, а только проверяет входящие.
- Access_token Keycloak — это JWT с подписью; проверка идёт локально по публичным ключам, без вызова Keycloak на каждый запрос.
- Публичные ключи Keycloak отдаёт по адресу JWKS своего realm; Spring их кеширует.
- Подключается одной зависимостью —
spring-boot-starter-oauth2-resource-server. - В
application.ymlхватает одной строки:issuer-uri(рекомендуется — даёт discovery и проверкуiss) либоjwk-set-uri(без обращения к Keycloak на старте). - Внутри запрос проходит конвейер:
BearerTokenAuthenticationFilterдостаёт токен из заголовка →JwtDecoderпроверяет подпись по JWKS,exp,nbf,iss→JwtAuthenticationConverterраскладывает роли → контроллер. SecurityFilterChainзадаёт, что требует токена, а что открыто; невалидный токен →401 Unauthorizedещё до вашего кода. 401 — «не знаю кто ты», 403 — «знаю, но прав нет».- Данные пользователя берут из
Jwt(@AuthenticationPrincipal JwtилиJwtAuthenticationToken); надёжный идентификатор — claimsub. - Роли Keycloak лежат в
realm_access.roles— чтобы Spring их увидел, нуженJwtAuthenticationConverterс префиксомROLE_.
Что почитать дальше
- Realm, client, роли и пользователи в Keycloak — как устроены пространства, приложения и роли, откуда берётся
realm_access.roles. - Authorization Code Flow и PKCE — как frontend получает тот самый Bearer-токен.
- Токены Keycloak: проверка, refresh, отзыв и ошибки — что внутри JWT, как устроена проверка по JWKS при смене ключей.
- Роли и доступ: RBAC и ABAC с Keycloak — что делать после проверки токена: проверка прав,
@PreAuthorize, владение ресурсом.