К вашему сервису приходит HTTP-запрос с заголовком Authorization: Bearer <длинная строка>. Эту строку — access_token — выдал Keycloak после того, как пользователь вошёл в систему. И прежде чем сервис выполнит хоть одну строчку бизнес-логики, он обязан ответить на простой вопрос: этот токен настоящий, его правда выпустил наш Keycloak, и срок ещё не вышел? Если не проверить — кто угодно подставит любую строку и получит доступ к чужим данным.
Хорошая новость: Spring Boot умеет проверять такие токены почти без вашего кода. Плохая — пока не понимаешь, через какие руки проходит запрос внутри Spring, всё выглядит магией: написал три строки конфигурации, и вдруг одни запросы пускаются, а другие отлетают с ошибкой 401. Разберём эту «магию» по косточкам — кто и в каком порядке проверяет токен.
Прежде чем разбирать этот путь по фильтрам, посмотрим, что даёт проверка токена на месте и чем за неё платят.
Локальная проверка по JWKS убирает вызов Keycloak из каждого запроса: пока Keycloak лежит минуту, introspection не пропускает ни одного из 600 запросов, а проверка по ключам из памяти — все 600. Плата — отозванный токен остаётся валидным до конца exp.
Зачем вообще проверять токен у себя, а не спрашивать 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 формата opaque для access_token нет вовсе. Он всегда выдаёт JWT. Спросить его по сети «этот токен ещё живой?» можно и про JWT — это называется introspection, — но это отдельное решение поверх того же JWT, а не второй формат. Opaque-строки отдают другие серверы аутентификации; эта статья не про них.
Где Keycloak хранит публичные ключи: JWKS
Чтобы проверить подпись локально, нужен публичный ключ Keycloak. Keycloak отдаёт свои публичные ключи по специальному адресу в виде набора ключей в формате JSON — это и называется JWKS (JSON Web Key Set). Для realm с именем myrealm адрес выглядит так:
https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
В адресе есть имя realm, и это не случайно: у каждого realm свой набор ключей в JWKS, и токен из одного realm не подойдёт к другому. Что такое realm и client и как они связаны — в статье про realm, client и роли.
Spring один раз скачает JWKS, закеширует ключи в памяти и дальше будет сверять подпись каждого входящего токена с этими ключами. К Keycloak за ключами он ходит редко — только когда Keycloak ротирует (меняет) ключи и встречается неизвестная подпись, — а не на каждый запрос.
Как Spring понимает, что ключи сменились: токен в заголовке несёт идентификатор ключа kid, и, встретив неизвестный kid, Spring один раз перезапрашивает JWKS. Поэтому недоступность Keycloak на минуты не мешает проверять токены — ключи уже в памяти.
У локальной проверки есть цена: отозванный токен остаётся валидным до exp. Поэтому access token живёт коротко, а отзыв делают через refresh token.
Шаг 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.
Важная деталь про «дальше». В Spring Boot 3 за визиткой идут не при старте приложения, а лениво — когда придёт первый запрос с токеном. Пока токенов нет, сервис к Keycloak не обращается вовсе.
Вариант Б — jwk-set-uri. Вы напрямую даёте адрес ключей:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
Когда это нужно. Раньше про вариант Б писали так: «зато сервис поднимется, даже если Keycloak лежит». Сегодня это не довод — в Spring Boot 3 и вариант А к Keycloak на старте не ходит, всё откладывается до первого токена. Оба варианта одинаково переживают лежащий при старте Keycloak.
Настоящих причин взять jwk-set-uri остаётся три, и все узкие: Keycloak стоит за прокси и снаружи отзывается не тем именем, которое записано у него внутри (тогда адрес из визитки окажется недостижимым); путь до ключей нестандартный; в контуре запрещён сам поход за визиткой. Во всех остальных случаях берите issuer-uri: он и адрес ключей найдёт сам, и проверку издателя включит даром — при jwk-set-uri эту проверку придётся добавлять руками.
Шаг 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.build();
}
}
Читается сверху вниз, как список правил:
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, а не чужому.
Обратите внимание, чего в этом списке нет: проверки аудитории — поля aud, то есть «кому этот токен вообще выписан». По умолчанию Spring её не делает. Для Keycloak это значит неприятную вещь: сервис примет любой токен своего realm — и токен, выписанный соседнему приложению, и даже id_token, который в API попадать не должен никогда. Подпись сходится, iss свой, exp не наступил — всё формально в порядке.
Чинится это одной строкой в том же application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/myrealm
audiences: order-service
Теперь токен без order-service в aud отлетит с 401. Со стороны Keycloak нужно позаботиться, чтобы ваш сервис туда вообще попадал: по умолчанию в aud его нет, имя добавляют протокол-маппером типа Audience на клиенте, от имени которого приходят запросы. Без этого шага вы закроете сервис от всех сразу, включая своих.
Если декодер всё одобрил, у нас есть разобранный токен — объект 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.
Но прежде чем разбирать токен руками, посмотрите на ответ: Spring уже написал, что не так. К 401 он добавляет заголовок WWW-Authenticate, и в нём лежит причина:
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token",
error_description="Jwt expired at 2026-09-25T09:14:02Z", error_uri="..."
Это главный инструмент диагностики в этой теме, и он бесплатный.
Типичные описания читаются прямо: Jwt expired at ... — истёк срок или разъехались часы; The iss claim is not valid — issuer-uri в настройках не совпадает с iss в токене, чаще всего из-за того, что Keycloak за прокси выдаёт токены с внутренним адресом; The aud claim is not valid — не сошлась аудитория, о чём раздел ниже; Signed JWT rejected: Another algorithm expected, or no matching key(s) found — токен подписан ключом другого realm. Когда заголовка нет вовсе, а 401 есть, значит токен не дошёл до декодера: не было заголовка Authorization или его срезал прокси.
Две оговорки. В браузере этот заголовок скрипт увидит только если он перечислен в Access-Control-Expose-Headers, иначе в консоли останется пустой 401 без причины. И в лог сервиса описание по умолчанию не пишется — при отладке уровень org.springframework.security ставят в DEBUG и получают ту же причину в журнале.
Как достать пользователя и его данные из токена
Токен прошёл проверку — теперь логике нужны данные из него: кто это, какая у него почта. Содержимое токена называется 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(...).
Проверка ролей на методах: @EnableMethodSecurity
Цепочка фильтров закрывает пути, и этого достаточно, пока правила описываются адресом. Как только правило звучит как «этот метод только для модератора» или «только владелец заказа», проверка переезжает на методы — и тут есть шаг, без которого она молча не работает.
Аннотации @PreAuthorize, @PostAuthorize, @PreFilter и @PostFilter выключены по умолчанию. Включает их отдельная аннотация на классе конфигурации:
@Configuration
@EnableMethodSecurity
public class SecurityConfig { }
Пока её нет, @PreAuthorize над методом не даёт ошибки и не проверяет ничего: метод спокойно выполняется для любого, кого пропустила цепочка фильтров. Это одна из самых неприятных ошибок в теме, потому что код выглядит защищённым, тесты на счастливый путь зелёные, а дыра обнаруживается в проде или на аудите. Проверяется это одним тестом: вызвать закрытую ручку токеном без нужной роли и потребовать 403.
Две детали про эту аннотацию. Она включает проверки на аннотациях Spring (prePostEnabled по умолчанию true); старые аннотации @Secured и JSR-250 (@RolesAllowed) включаются отдельными параметрами. И работает она через прокси, поэтому не действует на вызовы внутри того же класса, на private и на final методы — те же ограничения, что у @Transactional; разбор в статье про роли и доступ.
Опасная настройка: как нечаянно превратить 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 вовсе, дефолт делает ровно то, что нужно.
Как отличить сервисный вызов от пользовательского
Токен от соседнего сервиса приходит в тот же заголовок и проверяется той же подписью, поэтому по факту наличия токена сервис и человек не различаются никак. Различать приходится по содержимому, и полей для этого три.
Клиентские роли вместо пользовательских. Сервисный токен (client credentials) получен на учётную запись клиента, и роли у него лежат не в realm_access, а в resource_access.<client-id>.roles. Проверка выглядит так же, как для пользователя, только берётся другая ветка:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/internal/**").hasAuthority("ROLE_system")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll());
Роль system здесь должна попасть в права из клиентских ролей — это тот же конвертер ролей, только читающий resource_access, а лучше сразу оба места, чтобы конфигурация была одна на все токены.
azp (authorized party) называет клиента, которому выдан токен: order-service, web-app, mobile. По нему отвечают на вопрос «какое приложение пришло», и по нему же удобно писать проверку «на внутренний путь ходит только сервис заказов».
sub и признак служебной записи. У сервисного токена sub есть, и это не идентификатор человека, а идентификатор служебной записи клиента; preferred_username у неё выглядит как service-account-order-service. Полагаться на отсутствие sub нельзя — он там есть; правильный признак это имя служебной записи или отсутствие пользовательских ролей.
Практический вывод простой: внутренние пути разводят отдельным префиксом (/internal/**) и закрывают клиентской ролью, а не пускают сервисные вызовы в общие пути с пользовательскими правами. Иначе однажды придётся объяснять, почему сервис оплаты прошёл проверку, написанную для покупателя. Как вызывающая сторона получает такой токен и что ещё стоит проверить, разбирает статья про межсервисные вызовы.
CORS: почему фронтенд получает ошибку вместо ответа
Связка «одностраничное приложение в браузере плюс этот бэкенд» — самая частая из всех, и первый же запрос из неё обычно не проходит. В консоли браузера появляется blocked by CORS policy, а разработчик читает это как 401 и идёт проверять токен. Токен тут не при чём.
Механика такая. Запрос с заголовком Authorization на другой источник браузер считает непростым и перед ним отправляет предзапрос OPTIONS — без токена. Если сервис отвечает на него 401, браузер основной запрос даже не отправит. А отвечает он 401 именно потому, что Spring Security стоит и требует аутентификации на всё.
Настраивают это не в контроллере (@CrossOrigin до контроллера просто не доходит), а в самой цепочке фильтров:
http.cors(cors -> cors.configurationSource(request -> {
CorsConfiguration c = new CorsConfiguration();
c.setAllowedOrigins(List.of("https://shop.example.com"));
c.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
c.setAllowedHeaders(List.of("Authorization", "Content-Type", "Idempotency-Key"));
c.setMaxAge(3600L);
return c;
}));
Три места, где обычно ошибаются. Забывают Authorization в списке разрешённых заголовков — и предзапрос отвечает отказом, хотя источник разрешён.
Ставят allowedOrigins("*") вместе с cookie — такая пара браузером запрещена, для запросов с cookie нужен точный список источников и setAllowCredentials(true). И настраивают CORS в приложении, когда перед ним уже стоит шлюз, который тоже добавляет свои заголовки: два Access-Control-Allow-Origin в ответе браузер считает ошибкой, поэтому отвечает кто-то один.
Отдельная ловушка: ошибка CORS возникает и на стороне Keycloak, когда фронтенд идёт за токенами, — и лечится она не здесь, а полем «Web origins» у клиента. Разбор обеих сторон — в статье про Authorization Code Flow.
Как это собирается в Order Service маркетплейса
Соберём всё в один живой конфиг. Возьмём Order Service из кейса маркетплейса — сервис заказов, к которому ходят три разных вызывающих: витрина (покупатель), кабинет продавца и backoffice (сотрудник площадки). Плюс сам сервис принимает вызовы от Payment.
Что нужно от цепочки фильтров:
- каталог и карточки видны без входа — иначе поисковые роботы не увидят витрину;
- health-check отвечает мониторингу без токена;
- всё про заказы требует токена;
- отдельные операции требуют ролей — модерация и выплаты.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/catalog/**").permitAll()
.requestMatchers("/api/orders/**").hasRole("buyer")
.requestMatchers("/api/seller/**").hasRole("seller")
.requestMatchers("/api/moderation/**").hasRole("moderator")
.requestMatchers("/api/payouts/**").hasRole("finance")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(converter())))
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS));
return http.build();
}
Здесь STATELESS — это константа SessionCreationPolicy.STATELESS, её импортируют статически (import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;), иначе код не соберётся.
Две последние строки часто пропускают, а они важны именно для сервиса, живущего на токенах. STATELESS говорит: сессию на сервере не заводить — состояние целиком в токене, и любой инстанс сервиса обработает любой запрос. Без этого Spring создаст JSESSIONID, и при нескольких инстансах за балансировщиком начнутся плавающие ошибки. CSRF выключают потому, что защищаться нечему: атака работает через автоматически подставляемые браузером cookie, а токен в заголовке Authorization браузер сам не подставит. Но если вы всё же кладёте токен в cookie — CSRF-защиту выключать нельзя, иначе дыра.
Порядок правил здесь тоже не косметика. Правила читаются сверху вниз, до первого совпадения, поэтому GET /api/catalog/** стоит выше anyRequest().authenticated() — иначе каталог закрылся бы токеном. Классическая ошибка — поставить anyRequest() первым: тогда всё, что ниже, не сработает никогда, а выяснится это уже на витрине, закрытой от поисковиков.
Роли в этом конфиге — только грубый фильтр. Что покупатель ходит именно в свой заказ, hasRole("buyer") не проверяет: для этого нужна проверка владельца внутри сервиса — она разобрана в статье про роли и доступ.
Отдельная строка — вызовы от Payment. У них нет пользователя: Payment получает свой токен по client credentials и в нём нет ни buyer, ни seller. Такие вызовы либо разводят по отдельному пути (/internal/** с проверкой client-роли), либо закрывают сетевым уровнем и не выпускают наружу вовсе. Смешивать их с пользовательскими путями не стоит: правило hasRole("buyer") на общем пути отклонит сервисный токен, и отладка займёт вечер.
Как это тестировать
Конфигурация доступа — тот редкий код, ошибку в котором не видно ни в компиляторе, ни в обычных тестах: метод работает, просто открыт всем. Поэтому правила проверяют тестами, и их три вида.
Токен без всякого Keycloak. spring-security-test умеет подставлять готовую личность, и провайдер для этого не нужен:
@WebMvcTest(OrderController.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mockMvc;
@Test
void безТокенаНеПускает() throws Exception {
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isUnauthorized());
}
@Test
void безРолиОтвечает403() throws Exception {
mockMvc.perform(get("/api/orders/1").with(jwt()))
.andExpect(status().isForbidden());
}
@Test
void сРолью200() throws Exception {
mockMvc.perform(get("/api/orders/1")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_buyer"))
.jwt(j -> j.subject("42"))))
.andExpect(status().isOk());
}
}
jwt() — это SecurityMockMvcRequestPostProcessors.jwt(); он кладёт в контекст готовый Jwt с нужными правами и полями, минуя декодер и подпись. Соседний @WithMockUser делает то же самое проще, но подставляет обычную личность без объекта Jwt, поэтому код, который читает claims из токена, с ним не работает — для сервисов на токенах берут jwt().
Настоящий декодер, поддельные ключи. Когда надо проверить именно проверку токена (истёк, чужая аудитория, другой ключ), подменяют бин JwtDecoder на тот, что доверяет локальному ключу, и подписывают токены этим ключом в самом тесте. Тогда тест видит те же 401 с теми же описаниями в WWW-Authenticate, что и прод.
Настоящий Keycloak. Для сквозной проверки realm поднимают Keycloak в Testcontainers с импортом realm из файла, получают токен настоящим запросом и ходим с ним в сервис. Это медленно, поэтому таких тестов держат единицы — на вход, на роли и на аудиторию, — а остальное закрывают первым способом.
Что обязательно проверить, независимо от способа: запрос без токена отвечает 401, запрос с токеном без нужной роли — 403, чужой ресурс — 404 или 403 по вашему правилу, и предзапрос OPTIONS проходит. Эти четыре теста ловят почти все ошибки конфигурации, включая забытый @EnableMethodSecurity.
Глубже: aud: почему без него любой токен realm проходитрасширенное
Список проверок выше, подпись, exp, iss, выглядит полным и пропускает одну, из-за которой токен соседнего приложения из того же realm проходит в ваш API как родной. Это проверка аудитории, claim aud, и Spring по умолчанию её не делает.
Что лежит в aud у Keycloak по умолчанию: account, потому что у каждого пользователя есть роль в клиенте account, а Keycloak добавляет в аудиторию клиентов, роли которых попали в токен. Вашего API там нет, и любой access_token realm, для фронтенда, для мобильного приложения, для админки склада, одинаково валиден по подписи и издателю. То же с id_token: у него aud равен client id, и статья про токены обещает, что API его отвергнет, но отвергает его только проверка аудитории, которой нет.
Чинится с двух сторон. В Keycloak у client вашего API (или в общем client scope, который назначают всем фронтендам) добавляют mapper типа «Audience» с «Included Client Audience» равным client id API, например orders-api; после этого в токенах, выданных фронтенду, появляется aud: ["orders-api", "account"]. В Spring включают проверку:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/shop
audiences: orders-api
Свойство audiences добавляет к декодеру JwtClaimValidator, который отвергает токен без orders-api в aud с 401 и описанием The aud claim is not valid. Токен для другого сервиса и id_token теперь не проходят, и это единственный способ сделать утверждение «id_token в API получит 401» правдой.
Если у сервиса несколько клиентов-потребителей, у каждого свой mapper с той же аудиторией; если сервисов несколько, у каждого своя аудитория и своё значение audiences, о чём статья про межсервисные вызовы говорит применительно к client credentials. Проверить, что защита работает, можно тестом с подменённым JwtDecoder или настоящим Keycloak в Testcontainers, о чём раздел про тесты в статье про Spring Security: токен без нужной аудитории обязан дать 401.
Коротко
- В этой схеме сервис — OAuth2 Resource Server: он не выдаёт токены, а проверяет входящие, и делает это локально по публичным ключам, без вызова Keycloak на каждый запрос.
- Подключение — одна зависимость
spring-boot-starter-oauth2-resource-serverиissuer-uriв настройках (илиjwk-set-uri, когда Keycloak за прокси): ключи realm Spring находит сам, кеширует и идёт за ними лениво, на первом токене. - Внутри запрос идёт конвейером:
BearerTokenAuthenticationFilterдостаёт токен →JwtDecoderпроверяет подпись,exp,nbf,iss→JwtAuthenticationConverterраскладывает роли → контроллер. SecurityFilterChainзадаёт, что требует токена, а что открыто; невалидный токен даёт401ещё до вашего кода, а причину Spring пишет в заголовокWWW-Authenticate— читать её, а не разбирать токен руками.- Данные пользователя берут из
Jwt, надёжный идентификатор —sub; роли лежат вrealm_access.rolesи требуютJwtAuthenticationConverterс префиксомROLE_, а@PreAuthorizeвдобавок требует@EnableMethodSecurity— без неё проверки молча нет. - Для сервиса на токенах ставят
STATELESSи отключают CSRF, пока токен ездит в заголовке, а правилаSecurityFilterChainчитаются сверху вниз до первого совпадения: открытые пути вышеanyRequest(). - Вызовы сервис-сервису несут токен без пользовательских ролей: их разводят отдельным префиксом
/internal/**и закрывают клиентской ролью, а различают поresource_accessиazp— у служебной записиsubтоже есть. - Аудиторию Spring по умолчанию не проверяет, и без неё проходит любой токен realm вместе с id_token: в Keycloak — mapper «Audience» с client id API, в Spring —
audiences: orders-api. - Фронтенду нужен CORS в цепочке фильтров, а не в контроллере: предзапрос
OPTIONSидёт без токена,Authorizationперечисляют явно, а*вместе с cookie браузер не примет. - Тесты на конфигурацию обязательны:
jwt()изspring-security-testподставляет личность без Keycloak, подменённыйJwtDecoderпроверяет разбор токена, Testcontainers с настоящим realm — для единичных сквозных проверок.
Что почитать дальше
- Realm, client, роли и пользователи в Keycloak — как устроены пространства, приложения и роли, откуда берётся
realm_access.roles. - Authorization Code Flow и PKCE — как frontend получает тот самый Bearer-токен.
- Токены Keycloak: проверка, refresh, отзыв и ошибки — что внутри JWT, как устроена проверка по JWKS при смене ключей.
- Роли и доступ: RBAC и ABAC с Keycloak — что делать после проверки токена: проверка прав,
@PreAuthorize, владение ресурсом. - Order Service маркетплейса — сервис из сквозного кейса, на котором собран конфиг доступа из этой статьи.