Опирается на правила: AUTH-4AUTH-6 из Auth Patterns Style Guide → раздел 2. JWT validation.

Важно знать

  • JWT через oauth2ResourceServer().jwt() — стандарт Spring Security. Custom JWT-filter — запрещён.
  • JWK Set тянется из IdP по URL spring.security.oauth2.resourceserver.jwt.jwk-set-uri. Кеш — 5 минут default.
  • Вручную распаковывать ключи — запрещено. Spring сам делает rotation.
  • 401 — невалидная подпись или просроченный exp. Не аутентифицирован.
  • 403 — прав не хватает. Аутентифицирован, но не авторизован.
  • Путать 401/403 запрещено — это разные сценарии для клиента.

JWT validation — это место, где сервис проверяет «пришёл реальный пользователь или подделка». Один пропуск в проверке — и attacker подписывает любой токен своим ключом, заявляет себя админом, делает всё что хочет. UCP не позволяет custom-код в этой точке — только стандартный Spring Security.

Стандартный Spring Security

AUTH-4: oauth2ResourceServer().jwt().

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/actuator/health/**").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
            .build();
    }
}
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${IDP_JWK_SET_URI}
          issuer-uri: ${IDP_ISSUER_URI}

Что Spring Security делает за вас:

  • Парсит Authorization: Bearer <jwt> header.
  • Загружает JWK Set из IdP, кеширует 5 минут.
  • Проверяет подпись (RS256/ES256 через public key из JWK).
  • Проверяет exp — токен не просрочен.
  • Проверяет iss — issuer совпадает с issuer-uri.
  • Проверяет aud (если задано в JwtDecoder).
  • Парсит claims в Jwt объект.
  • Конвертирует authorities через JwtAuthenticationConverter.

Никакого custom-кода для этого писать не надо. Если кажется, что надо — это симптом, разбираемся, что именно отсутствует в стандарте.

Custom JWT-filter — запрещён

// КАТАСТРОФА
public class CustomJwtFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(...) {
        var token = request.getHeader("Authorization").substring(7);
        var jwt = new Jwts.parser()...parse(token);
        if (jwt.getBody().get("exp", Long.class) < System.currentTimeMillis() / 1000) {
            throw new RuntimeException("expired");
        }
        // ... ещё 50 строк ручной валидации
    }
}

Что ломается:

  • Забыли проверить iss — attacker подсунул токен от своего IdP.
  • Забыли проверить aud — токен от другого микросервиса принят.
  • Неверная проверка exp (clock skew, timezone).
  • Не валидирована алгоритм в header — alg: none accepted.
  • JWK не cached → каждый запрос дёргает IdP.
  • При rotation ключа — invalidation не работает.

Каждая ошибка — критическая уязвимость. Spring Security уже решил это в production-tested коде.

JWK Set и кеш

AUTH-5: ключи из IdP.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://idp.example.com/realms/main/protocol/openid-connect/certs

При старте Spring загружает JWK Set, кеширует. Cache TTL — 5 минут по умолчанию. При получении JWT с kid (key id) которого нет в кеше — Spring перезапрашивает JWK Set (force refresh). Это покрывает rotation IdP-ключей.

Не нужно:

  • Хранить публичные ключи в application.yml или файле.
  • Распаковывать JWK вручную через Jwks.parser().
  • Писать собственный refresh-механизм.

Если IdP лежит при старте — сервис не пройдёт health check (см. Health checks). Если IdP лежит во время работы и нужно проверить токен с новым kid — Spring пробует refresh, при failure отдаёт 401.

401 vs 403

AUTH-6: семантика кодов критична для клиента.

КодКогдаЧто должен сделать клиент
401 UnauthorizedНевалидный JWT: bad signature, expired, missing Authorization headerRefresh-token flow или relogin
403 ForbiddenJWT валиден, но прав не хватает: @PreAuthorize отказал, ABAC отказалПоказать «доступ запрещён», не пытаться refresh

Сценарии:

  • 401 на expired token → клиент дергает POST /oauth/token с refresh-token, получает новый access-token, retry.
  • 403 на POST /admin/orders от роли CUSTOMER → клиент показывает «вам недоступно», не делает refresh (refresh не вернёт админскую роль).

Путать запрещено. Сценарий поломки:

// ПЛОХО — везде 403
.exceptionHandling(eh -> eh
    .authenticationEntryPoint((req, resp, e) -> resp.setStatus(403))
    .accessDeniedHandler((req, resp, e) -> resp.setStatus(403)))

Клиент видит 403 на expired token, думает «прав не хватает», не делает refresh, остаётся в зависшем состоянии. Корректно:

.exceptionHandling(eh -> eh
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    .accessDeniedHandler((req, resp, e) -> resp.setStatus(HttpStatus.FORBIDDEN.value())))

Spring Security по умолчанию это и делает — главное не переопределить ошибочно.

Что запрещено

АнтипаттернПравилоЧто взамен
Custom JWT-filterAUTH-4oauth2ResourceServer().jwt()
Распаковка JWK вручнуюAUTH-5jwk-set-uri + Spring cache
Хранение public key в application.ymlAUTH-5загрузка из IdP runtime
401 вместо 403 (или наоборот)AUTH-6401 — bad auth, 403 — bad perm
alg: none acceptedAUTH-4Spring отклоняет, custom может пропустить
Cache JWK > 5 минут без rotation handlingAUTH-5дефолт 5 мин + auto-refresh
Проверка exp без clock skewAUTH-4Spring учитывает skew автоматически
Парсинг JWT через Jwts.parser() (jsonwebtoken)AUTH-4Spring JwtDecoder

Куда дальше

  • Auth → раздел 2. JWT validation — нормативные формулировки.
  • Где какая проверка — Gateway vs BFF vs Domain.
  • RBAC: маппинг ролей — JwtAuthenticationConverter.
  • Service-to-service — mTLS как альтернатива JWT для internal.
  • PII и секреты — client-secret через env / Vault.