Опирается на правила:
AUTH-4…AUTH-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: noneaccepted. - 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 header | Refresh-token flow или relogin |
| 403 Forbidden | JWT валиден, но прав не хватает: @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-filter | AUTH-4 | oauth2ResourceServer().jwt() |
| Распаковка JWK вручную | AUTH-5 | jwk-set-uri + Spring cache |
Хранение public key в application.yml | AUTH-5 | загрузка из IdP runtime |
401 вместо 403 (или наоборот) | AUTH-6 | 401 — bad auth, 403 — bad perm |
alg: none accepted | AUTH-4 | Spring отклоняет, custom может пропустить |
| Cache JWK > 5 минут без rotation handling | AUTH-5 | дефолт 5 мин + auto-refresh |
Проверка exp без clock skew | AUTH-4 | Spring учитывает skew автоматически |
Парсинг JWT через Jwts.parser() (jsonwebtoken) | AUTH-4 | Spring JwtDecoder |
Куда дальше
- Auth → раздел 2. JWT validation — нормативные формулировки.
- Где какая проверка — Gateway vs BFF vs Domain.
- RBAC: маппинг ролей —
JwtAuthenticationConverter. - Service-to-service — mTLS как альтернатива JWT для internal.
- PII и секреты —
client-secretчерез env / Vault.