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

К вашему сервису приходит HTTP-запрос с заголовком Authorization: Bearer <длинная строка>. Эту строку — access_token — выдал Keycloak после того, как пользователь вошёл в систему. И прежде чем сервис выполнит хоть одну строчку бизнес-логики, он обязан ответить на простой вопрос: этот токен настоящий, его правда выпустил наш Keycloak, и срок ещё не вышел? Если не проверить — кто угодно подставит любую строку и получит доступ к чужим данным.

Хорошая новость: Spring Boot умеет проверять такие токены почти без вашего кода. Плохая — пока не понимаешь, через какие руки проходит запрос внутри Spring, всё выглядит магией: написал три строки конфигурации, и вдруг одни запросы пускаются, а другие отлетают с ошибкой 401. Разберём эту «магию» по косточкам — кто и в каком порядке проверяет токен.

Прежде чем разбирать этот путь по фильтрам, посмотрим, что даёт проверка токена на месте и чем за неё платят.

одна минута работы · 10 запросов в секунду · всего 600 introspection — спрашивать Keycloak проверка = сетевой вызов на каждый запрос JWT + JWKS — проверять подпись локально ключи скачаны один раз и лежат в памяти вызовов в Keycloakзапросов прошло вызовов в Keycloak0запросов прошло600 из 600 600600 из 600 600 впустую0 из 600✗ Keycloak молчит отзыв замечен на следующем запросегоден ли токен, решает сам Keycloakплюс introspectionтокен работает до конца срока expпоэтому access_token живёт минуты, отзыв — через refreshобратная сторона Keycloak жив — вся разница в 600 лишних вызовах Keycloak лежит минуту — у introspection не прошёл ни один запрос плата за локальную проверку — отозванный токен живёт до exp

Локальная проверка по 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, и только потом управление получает контроллер.

Запрос GET /orders с access_token идёт через BearerTokenAuthenticationFilter, JwtDecoder проверяет подпись по JWKS, converter раскладывает роли в authorities, контроллер отдаёт 200 OK

Ключевое, что стоит вынести: к моменту, когда ваш код в контроллере начинает работать, токен уже проверен — подпись сошлась, срок не истёк, издатель свой. Вам не нужно ничего проверять руками.

Что происходит при невалидном токене: ответ 401

Теперь обратная ветка — самая частая причина недоумения новичка: «почему мой запрос возвращает 401?». Невалидным токен может оказаться на любом из шагов. Например:

  • заголовка Authorization вообще нет (забыли приложить токен);
  • токен просрочен — поле exp уже в прошлом;
  • подпись не сходится — токен подделан или выпущен чужим Keycloak;
  • издатель чужой — iss не совпал с вашим realm.

Во всех этих случаях JwtDecoder бракует токен, фильтр не кладёт аутентификацию в SecurityContext, и сработавший обработчик (AuthenticationEntryPoint) возвращает клиенту 401 Unauthorized. Важнейшая деталь: это происходит до вашего контроллера — ваш код вообще не вызывается. Бизнес-логика защищена ещё на входе.

Не путайте два кода ответа. 401 Unauthorized — «я не знаю, кто ты»: токена нет или он невалиден. 403 Forbidden — «я знаю, кто ты, но прав не хватает»: токен валиден, пользователь аутентифицирован, но у него нет нужной роли для этого действия. 401 — про проверку токена (эта статья), 403 — про проверку прав.

Что на схеме: тот же путь, но JwtDecoder забраковал токен, и клиент получает 401, не доходя до контроллера.

Запрос GET /orders с плохим токеном: JwtDecoder видит несошедшуюся подпись или истёкший exp, фильтр возвращает клиенту 401 Unauthorized, контроллер не вызывается

Если в логах вы видите 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 не запускает — и пользователь оказывается в тупике: разлогинить себя клиент не считает нужным, а пускать его сервис не пускает.

дефолт: 401 истёк 401 refresh новый access 200 OK подмена: 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 — для единичных сквозных проверок.

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