В прошлой статье мы развернули Keycloak, настроили realm и получили первый access-token — JWT с iss, aud=backend, exp и realm_access.roles. Теперь у токена появляется потребитель: бэкенд, который этот токен проверяет и по нему решает, пускать запрос или нет. И здесь возникает вопрос с прямыми последствиями для производительности и надёжности: проверять токен локально или спрашивать Keycloak на каждый запрос?
Ответ не умозрительный. Бэкенд может валидировать подпись JWT сам, по опубликованным ключам realm (JWKS), не трогая Keycloak вовсе, — либо ходить на introspection-эндпоинт IdP за вердиктом на каждый входящий запрос. Разница в цене — не «на глазок», а измеренная: стенд digital-cookbook: security/keycloak гоняет оба режима на двух зеркальных resource server’ах (Go и Java) и снимает p50/p95/p99 и число запросов, реально дошедших до Keycloak. Все числа ниже — с этого прогона, не придуманы.
В статье
- Бэкенд как OIDC resource server
- Два пути валидации: локально по JWKS против introspection
- Что именно проверяем: alg, подпись, iss, aud, exp/nbf, typ
- Go: discovery, JWKS-кэш, middleware, роли
- Java: Spring Security oauth2ResourceServer
- Тонкость issuer: KC_HOSTNAME фиксирует iss
- Замер нагрузки: JWKS против introspection
- Когда introspection всё же нужен и как его удешевить
- Тюнинг lifespans: свежесть отзыва против нагрузки
- Роль из токена — не разрешение: мост к authz
- Итог
- Источники
Бэкенд как OIDC resource server
Ключевая мысль всей интеграции: бэкенд не «интегрируется с Keycloak» — он становится обычным OIDC resource server. Он принимает access-token в заголовке Authorization: Bearer <jwt>, проверяет его и, если токен валиден и в нём есть нужная роль, выполняет запрос. Переносимо здесь ядро подхода — discovery + JWKS + проверка подписи, iss, aud, exp: оно программируется против стандарта OIDC, а не против конкретного IdP. Но «код вообще не изменится при смене провайдера» было бы преувеличением, и статья на этом не настаивает: verifier в стенде переносимый (access-token JWT/JWKS с явной проверкой alg/iss/aud/exp/nbf, не завязан на Keycloak), но извлечение ролей опирается на Keycloak-специфичный claim realm_access.roles — у другого IdP роли лежат иначе, и этот маппинг придётся адаптировать (подробно — в Go-разделе). Переносится архитектура resource server; «zero-diff на любом OIDC-провайдере» — нет.
На стенде два таких resource server’а — на Go и на Java, — и они зеркалят друг друга ровно по контракту:
| Эндпоинт | Требование | Ответ |
|---|---|---|
/public |
токен не нужен | 200 всегда |
/me |
любой валидный токен | 200 + {sub, roles}; без токена — 401 |
/admin |
валидный токен + роль admin |
200 для bob; alice (без admin) — 403 |
Три кода — 200 / 401 / 403 — покрывают логику доступа: 401 «кто ты» (токен отсутствует или невалиден), 403 «тебе сюда нельзя» (токен валиден, но роли не хватает), 200 «проходи». К ним добавляется четвёртый исход, но он не про доступ, а про доступность: если в режиме introspection бэкенд не смог достучаться до IdP (таймаут, 5xx, обрыв), корректный ответ — 503, а не 401 (об этой семантике — ниже). Оба сервера дают идентичные коды и идентичные тела /me на одних и тех же токенах — контракт resource server не зависит от языка, потому что задаётся стандартом.
Два пути валидации: локально по JWKS против introspection
Проверить access-token можно двумя принципиально разными способами, и выбор между ними — центральное архитектурное решение статьи.
Локальная валидация по JWKS (offline). JWT самодостаточен: он подписан приватным ключом realm, а публичный ключ Keycloak публикует по стандартному адресу — JWKS-эндпоинту (/protocol/openid-connect/certs). Бэкенд один раз забирает этот набор ключей, кэширует его и дальше проверяет подпись каждого токена локально, в памяти, без единого сетевого вызова к Keycloak. Ключ выбирается по полю kid в заголовке токена; при ротации ключей realm бэкенд видит незнакомый kid и обновляет кэш JWKS. Токен несёт в себе всё нужное для проверки — подпись, iss, aud, exp, роли, — поэтому IdP на горячем пути не участвует.
Introspection (online). Бэкенд отдаёт токен обратно Keycloak — на introspection-эндпоинт (/protocol/openid-connect/token/introspect, RFC 7662) — и спрашивает: «этот токен ещё активен?». Keycloak сверяется со своим состоянием и отвечает active: true/false плюс claims. Это один сетевой round-trip и работа IdP на каждый входящий запрос. Взамен — мгновенная реакция на отзыв: если токен отозвали в Keycloak, introspection тут же вернёт active: false, тогда как локально проверяемый JWT останется валидным до своего exp.
Authorization: Bearer JWT"] --> RS["Resource server
(Go / Java)"] RS --> Mode{"Режим
валидации"} Mode -->|"jwks (offline)"| Cache["Кэш JWKS в памяти"] Cache -->|"промах по kid:
обновить ключи"| JWKS["Keycloak JWKS
/certs (редко)"] Cache --> Verify["Проверка alg + подписи + iss/aud/exp/nbf + typ
локально, 0 обращений к IdP"] Verify --> Roles["realm_access.roles →
решение 200 / 403"] Mode -->|"introspect (online)"| Intro["POST /token/introspect
к Keycloak — на КАЖДЫЙ запрос"] Intro --> KC["Keycloak
active? + claims"] KC --> Roles
flowchart TD
Req["Запрос:
Authorization: Bearer JWT"] --> RS["Resource server
(Go / Java)"]
RS --> Mode{"Режим
валидации"}
Mode -->|"jwks (offline)"| Cache["Кэш JWKS в памяти"]
Cache -->|"промах по kid:
обновить ключи"| JWKS["Keycloak JWKS
/certs (редко)"]
Cache --> Verify["Проверка alg + подписи + iss/aud/exp/nbf + typ
локально, 0 обращений к IdP"]
Verify --> Roles["realm_access.roles →
решение 200 / 403"]
Mode -->|"introspect (online)"| Intro["POST /token/introspect
к Keycloak — на КАЖДЫЙ запрос"]
Intro --> KC["Keycloak
active? + claims"]
KC --> Roles
Дефолт для высоконагруженного resource server — локальная JWKS-валидация: она практически бесплатна для IdP. Introspection — специальный инструмент для случаев, где нужна мгновенная отзывность (о них — ниже). Насколько именно бесплатна одна и дорога другая — измерим на стенде.
Что именно проверяем: alg, подпись, iss, aud, exp/nbf, typ
Прежде чем доверять содержимому токена, resource server обязан пройти цепочку проверок. Порядок важен: сначала — что токен подлинный и адресован нам, и только потом — что в нём написано.
- Алгоритм подписи (
alg) — сверяется с единственным ожидаемым (у нас RS256), а не «любым из списка». Это закрываетalg=noneи downgrade на слабый/симметричный алгоритм (JWT BCP, RFC 8725). - Подпись — по публичному ключу realm из JWKS, ключ выбирается по
kidиз заголовка токена (и связывается с тем же ожидаемым алгоритмом). Проваленная подпись — подделка или чужой realm → 401. Это фундамент: без валидной подписи всё остальное содержимое токена — набор байт, которым нельзя верить. iss(issuer) — должен точно совпадать с адресом realm, по которому бэкенд делал discovery. Несовпадение issuer — токен из другого realm/IdP → 401. Именно здесь всплывает тонкость сKC_HOSTNAME(см. ниже).aud(audience) — должен содержатьbackend. Audience-проверка не даёт использовать токен, выписанный для одного сервиса, против другого: токен дляfrontend-only не пройдёт на бэкенде, требующемaud=backend.exp/nbf— токен не истёк и уже действителен, с поправкой на допустимый clock skew между узлами.typ(тип токена) — должен бытьBearer, то есть access-token. Это отсекает подстановкуid_token(typ:ID) или refresh-токена (typ:Refresh) с валидной подписью и подходящими claims. Тонкость: в Keycloak тип лежит в claimtyp, а не в JWT-header (там всегдаJWT).
Только после того как все эти проверки пройдены, бэкенд извлекает claims для авторизации — realm_access.roles (realm-роли), при необходимости resource_access.<client>.roles (client-роли) и scopes — и на их основе решает, пускать ли запрос к конкретному эндпоинту. На стенде роль берётся из realm_access.roles: bob с ролью admin проходит на /admin, alice без неё — получает 403.
В Java эти шаги выполняет Spring Security; в Go мы делаем их явно на go-jose — для access-токена это надёжнее, чем брать готовый id-token-верификатор (почему — чуть ниже). Посмотрим оба.
Go: discovery, JWKS-кэш, middleware, роли
На Go resource server использует github.com/coreos/go-oidc/v3 (v3.16.0) — только для discovery, и github.com/go-jose/go-jose/v4 — для самой проверки JWT. При старте сервис делает OIDC discovery по issuer, берёт из него jwks_uri и кэширует ключи (обновляя по kid при ротации):
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, httpClient), cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc discovery %q: %w", cfg.Issuer, err)
}
// go-oidc используем ТОЛЬКО для discovery (jwks_uri) — это поддерживаемый вызов.
var disc struct{ JWKSURL string `json:"jwks_uri"` }
if err := provider.Claims(&disc); err != nil {
return nil, fmt.Errorf("read discovery: %w", err)
}
v.issuer = cfg.Issuer
v.jwksURL = disc.JWKSURL
v.expectedAlg = jose.SignatureAlgorithm(orDefault(cfg.ExpectedAlg, "RS256")) // ОДИН алгоритм
v.expectedTyp = orDefault(cfg.ExpectedTyp, "Bearer") // тип access-токена KC
if err := v.refreshJWKS(ctx); err != nil { // свой кэш JWKS
return nil, err
}Ключевой момент — чем проверяется подпись. Мы не используем oidc.IDTokenVerifier (он для id_token) и не зовём RemoteKeySet.VerifySignature напрямую: go-oidc помечает этот метод как «Users MUST NOT call this method directly» — он пропускает часть проверок и экспортирован лишь для реализаций KeySet. Вместо этого — go-jose, дающий полный контроль: единственный ожидаемый алгоритм, проверка typ, связывание ключа с алгоритмом.
func (v *Verifier) validateJWKS(ctx context.Context, raw string) (Principal, error) {
// 1. Разбор строго с ОДНИМ ожидаемым алгоритмом: go-jose отвергнет любой другой,
// включая none и «разрешённый, но не ожидаемый» (RS512 при ожидаемом RS256).
tok, err := jose.ParseSigned(raw, []jose.SignatureAlgorithm{v.expectedAlg})
if err != nil {
return Principal{}, fmt.Errorf("parse jws: %w", err)
}
hdr := tok.Signatures[0].Header
// 2. Ключ по kid + связывание ключ↔алгоритм (RFC 8725: один ключ — один алгоритм).
key, err := v.keyByKID(ctx, hdr.KeyID)
if err != nil {
return Principal{}, err
}
if key.Algorithm != "" && key.Algorithm != string(v.expectedAlg) {
return Principal{}, errors.New("key alg mismatch")
}
// 3. Проверка подписи ключом.
payload, err := tok.Verify(key)
if err != nil {
return Principal{}, fmt.Errorf("verify signature: %w", err)
}
// 4. Явные проверки claims. typ ПЕРВЫМ: в Keycloak тип токена — в CLAIM typ
// (Bearer/ID/Refresh), а НЕ в header; так отсекается подстановка id_token.
var c accessClaims // typ, iss, aud[], exp, nbf, sub, realm_access.roles
if err := json.Unmarshal(payload, &c); err != nil {
return Principal{}, err
}
now := time.Now()
switch {
case c.Typ != v.expectedTyp: // "Bearer" — access-token, не id_token/refresh
return Principal{}, errors.New("token typ mismatch")
case c.Issuer != v.issuer:
return Principal{}, errors.New("issuer mismatch")
case !c.Audience.contains(v.audience):
return Principal{}, errors.New("audience mismatch")
case c.Expiry == 0 || now.After(time.Unix(c.Expiry, 0).Add(leeway)):
return Principal{}, errors.New("token expired")
case c.NotBefore != 0 && now.Add(leeway).Before(time.Unix(c.NotBefore, 0)):
return Principal{}, errors.New("token not yet valid (nbf)")
}
return Principal{Subject: c.Subject, Roles: c.RealmAccess.Roles}, nil
}Почему так, а не короче через готовый id-token-верификатор? Потому что на входе access-token, и корректная проверка следует JWT BCP (RFC 8725): один ожидаемый алгоритм (а не «любой из девяти»), связывание ключа с алгоритмом и проверка типа — чтобы id_token с валидной подписью и подходящими claims не прошёл как access-token. В Keycloak тип лежит в claim typ (Bearer у access, ID у id-token), его и проверяем; в профиле RFC 9068 это был бы header typ: at+jwt.
Про переносимость — честно, без перебора. Скелет (discovery + JWKS + подпись + iss/aud/exp/nbf) стандартен и переносим. Но «работает с любым IdP без правок» — преувеличение: формат access-токена не стандартизован жёстко (JWT против opaque), а имя claim для типа, набор обязательных claims и способ хранения ролей зависят от профиля провайдера. На другом IdP поменяются ExpectedTyp, извлечение ролей (у нас realm_access.roles), возможно ExpectedAlg. Переносится подход и почти весь код — но не «ноль правок». Негативные случаи закрыты unit-тестами (verifier_test.go, RSA-ключ и JWKS — в тесте): истёкший токен, nbf в будущем, чужой aud/iss, alg=none, подпись чужим ключом, подстановка id_token (typ:ID) и неожиданный алгоритм (RS512 при ожидаемом RS256). В Java ту же работу делает Spring Security — JwtDecoder + JwtAuthenticationConverter.
Отдельная тонкость JWKS-режима — ротация ключей. Когда realm меняет подписной ключ, в токенах появляется новый kid, которого нет в кэше. Наивная реакция «незнакомый kid → сходи за JWKS» открывает амплификацию: поток JWT со случайными kid (подпись даже не нужна — kid читается до её проверки) превратит JWKS в online-зависимость горячего пути — ровно то, чего JWKS-режим и должен избегать. Поэтому on-demand refetch ограничен токен-бакетом (в стенде — burst 5, установившийся темп 1/с) и дополнен фоновым проактивным обновлением JWKS (по умолчанию раз в 5 минут). Компромисс честный и его нельзя обойти: при штатной работе новый ключ подхватывается мгновенно, а под потоком мусорных kid (бакет исчерпан) легитимная ротация принимается не позднее следующего успешного фонового обновления (обычно ≤ интервала, дефолт 5 минут; строгой верхней границы нет, только если сам JWKS недоступен — тогда обновление ждёт восстановления). Абсолютно мгновенную ротацию при любом объёме мусора и неограниченную защиту IdP одновременно гарантировать нельзя — это закрыто тестами (amplification, rotation, background refresh, error backoff).
Доступ разграничивает HTTP-middleware: оно достаёт Bearer-токен, валидирует его и, если для эндпоинта задана обязательная роль, проверяет её наличие. Коды ответа ровно те, что в контракте: нет/невалиден токен — 401, нет роли — 403, а операционный сбой обращения к IdP (в режиме introspection) — 503, а не 401 (о семантике — ниже).
func (v *Verifier) Middleware(next http.Handler, requiredRole string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := bearerToken(r)
if err != nil {
v.deny(w, http.StatusUnauthorized, "missing bearer token") // 401
return
}
principal, err := v.validate(r.Context(), raw)
if err != nil {
// Операционный сбой IdP (сеть/таймаут/5xx/битый ответ) ≠ невалидный токен:
// отдаём 503 + Retry-After, чтобы клиент повторил, а не переавторизовывался.
if errors.Is(err, ErrUpstreamUnavailable) {
v.deny(w, http.StatusServiceUnavailable, "validation unavailable") // 503
return
}
v.deny(w, http.StatusUnauthorized, "invalid token") // 401
return
}
if requiredRole != "" && !hasRole(principal.Roles, requiredRole) {
v.deny(w, http.StatusForbidden, "missing required role") // 403
return
}
ctx := context.WithValue(r.Context(), ctxKey{}, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Регистрация роутов делает /admin role-gated одной строкой — middleware с требуемой ролью admin:
mux.HandleFunc("/public", handlePublic) // без токена
mux.Handle("/me", verifier.Middleware(http.HandlerFunc(handleMe), "")) // любой валидный токен
mux.Handle("/admin", verifier.Middleware(http.HandlerFunc(handleAdmin), "admin")) // роль adminЖивой прогон подтверждает контракт: /public без токена — 200, /me без токена — 401, /me для alice — 200, /admin для alice — 403, /admin для bob — 200, битый токен на /me — 401.
Java: Spring Security oauth2ResourceServer
Java-зеркало (Spring Boot 3.4.2, Java 21) делает то же самое, но большую часть работы берёт на себя автоконфигурация Spring Security. Достаточно указать issuer — discovery, JWKS-кэш и проверка подписи/iss/exp включаются сами; aud и typ навешиваем своими валидаторами (ниже):
spring:
security:
oauth2:
resourceserver:
jwt:
# JWKS подтягивается лениво из discovery по issuer.
issuer-uri: ${KC_ISSUER:http://keycloak:8080/realms/demo}
kc:
audience: ${KC_AUDIENCE:backend}Что настраивается вручную, потому что специфично для Keycloak. Первое — audience и тип токена: дефолтный декодер Spring проверяет iss и exp, но не aud и не typ. Поэтому поверх дефолтных навешиваем два валидатора — AudienceValidator (aud=backend) и TokenTypeValidator (typ=Bearer — отсекает подстановку id_token/refresh, точное зеркало Go).
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties,
@Value("${kc.audience:backend}") String audience,
@Value("${kc.token-type:Bearer}") String tokenType) {
String issuerUri = properties.getJwt().getIssuerUri();
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).build();
// Дефолт (iss + exp) + свои валидаторы aud=backend и typ=Bearer.
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
withIssuer,
new AudienceValidator(audience),
new TokenTypeValidator(tokenType)); // typ=Bearer в claim (не header)
decoder.setJwtValidator(validators);
return decoder;
}Второе — роли. Keycloak кладёт realm-роли в вложенный claim realm_access.roles, а Spring по умолчанию ищет их в scope/scp. Поэтому нужен конвертер, который достаёт роли из realm_access.roles и мапит их в ROLE_<role> — Spring-овый префикс, по которому потом работают hasRole(...) и @PreAuthorize:
@SuppressWarnings("unchecked")
static Collection<GrantedAuthority> extractRealmRoles(Jwt jwt) {
Object realmAccess = jwt.getClaim("realm_access");
if (!(realmAccess instanceof Map<?, ?> map)) {
return List.of();
}
Object roles = map.get("roles");
if (!(roles instanceof Collection<?> roleList)) {
return List.of();
}
return roleList.stream()
.map(Object::toString)
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
}Дальше правила доступа декларируются в фильтр-цепочке — тот же контракт, что на Go, но выраженный матчерами путей:
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public").permitAll()
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/admin").hasRole("admin") // 403 без роли admin
.requestMatchers("/me").authenticated() // 401 без валидного токена
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));Живой прогон Java даёт коды и тела /me, идентичные Go на тех же токенах: bob /me → 200 с sub и roles (включая admin), alice /admin → 403. Контракт resource server от языка не зависит — меняется только синтаксис, которым он выражен.
Тонкость issuer: KC_HOSTNAME фиксирует iss
Один нюанс проявляется сразу, как только бэкенд и Keycloak оказываются в разных сетевых точках, — и стоит разобрать его прямо, потому что без него первый же токен получит 401.
Проверка issuer требует точного совпадения iss в токене с адресом, по которому бэкенд делал discovery. В start-dev без фиксированного hostname Keycloak строит iss из заголовка Host того запроса, которым забирали токен. Токен, взятый с хоста по http://localhost:8080/..., получает iss=http://localhost:8080/realms/demo. А бэкенд внутри docker-сети делает discovery по http://keycloak:8080/realms/demo — и ожидает именно такой issuer. Issuer’ы не совпадают → валидная во всём остальном подпись отклоняется, запрос падает в 401.
Правильное решение — не ослаблять проверку (не выключать issuer-check), а зафиксировать issuer с обеих сторон переменной KC_HOSTNAME:
keycloak:
environment:
KC_HOSTNAME: http://keycloak:8080 # iss = http://keycloak:8080/realms/demo, откуда бы ни пришёл запросТеперь issuer стабилен независимо от того, с какого адреса берут токен: и токен, полученный с хоста по проброшенному порту, и discovery бэкенда изнутри сети дают один и тот же iss=http://keycloak:8080/realms/demo. Соблазн обойти проблему флагом «пропускать проверку issuer» стоит отвергнуть: он ослабляет валидацию, а фиксация hostname — каноничная практика Keycloak, которая к тому же обязательна в production (подробнее — в статье про productionготовится, с 16 сентября). Механику самого token endpoint и состав JWT разбирает вторая статья серии.
Замер нагрузки: JWKS против introspection
Теперь — ядро статьи. Разница между «проверять локально» и «спрашивать IdP на каждый запрос» звучит убедительно на словах, но её стоит измерить. Стенд делает ровно это: нагрузчик шлёт запросы на /me Go-бэкенда, а бэкенд переключается между режимами jwks и introspect одним пересозданием контейнера. Параллельно снимается метрика Keycloak — сколько запросов реально дошло до introspection-эндпоинта.
Параметры прогона (важны для честности чисел):
- 20 воркеров × 2000 запросов = 40 000 запросов на
/me, плюс 500 warmup; - нагрузчик и бэкенд — внутри docker-сети стенда (
backend-go:8081,keycloak:8080), а не через опубликованный порт с хоста: замер через NAT Docker Desktop на Windows искажался, а сеть контейнер-контейнер репрезентативна для прод-сценария service-to-service; - токен — от
bob(ролиadmin,user),aud=backend;accessTokenLifespanвременно поднят до 900 с, чтобы токен не истёк за прогон; - Keycloak 26.7.0 (
start-dev, PostgreSQL 16), backend-go на go-jose (discovery через go-oidc v3), всё на одном хосте; - число обращений к Keycloak считается по его же метрике
http_server_requests_seconds_count{uri=".../token/introspect"}— дельта до и после прогона.
Результат — на реальном выводе:
| Режим | p50, мс | p95, мс | p99, мс | Throughput, req/s | Вызовов /introspect / запросов | Ошибки |
|---|---|---|---|---|---|---|
jwks |
1.18 | 3.89 | 5.57 | 12 599 | 0 / 40 000 | 0 |
introspect |
4.19 | 11.02 | 16.16 | 3 928 | 40 500 / 40 000 | 0 |
Про методику и воспроизводимость. Это один прогон на конкретной машине: scripts/bench.sh со стенда по умолчанию гоняет ровно эту конфигурацию — 20 воркеров × 2000 запросов + 500 warmup — и печатает такую же таблицу, а после себя возвращает accessTokenLifespan к 300 (через trap, чтобы прерванный прогон не оставил изменённый realm). Абсолютные миллисекунды и req/s зависят от машины и конкретного прогона — гнаться за их точным повторением бессмысленно; воспроизводимо и принципиально другое: число обращений к IdP (структурно 0 против 1 на запрос) и порядок разрыва (локальная валидация в разы дешевле по latency и throughput). Именно это, а не конкретное 1.18 против 4.19, — вывод замера.
Три вывода из этой таблицы:
-
Локальная JWKS-валидация практически бесплатна для IdP. За весь прогон — ноль вызовов
/introspect: ключи JWKS закэшированы у бэкенда (свой кэш с обновлением поkid), IdP на горячем пути не тронут ни разу. p50 ~1.2 мс, а throughput (≈12 600 req/s) ограничен только самим бэкендом, не IdP. -
Introspection стоит один round-trip и работу IdP на каждый запрос. Счётчик обращений — прямое тому доказательство: 40 500 = 40 000 замеряемых + 500 warmup, то есть ровно 1 обращение к Keycloak на каждый запрос. Это платится latency и пропускной способностью: p50 хуже в ×3.5 (4.19 против 1.18 мс), p99 — в ×2.9, throughput — в ×3.2 (3 928 против 12 599 req/s). И это на «здоровом», ненасыщенном уровне нагрузки.
-
С ростом конкуренции introspection деградирует нелинейно, а IdP становится узким местом и точкой отказа. Отдельный прогон на 50 воркерах (100 000 запросов) в introspect-режиме насыщал Keycloak: p50 подскакивал до 18.4 мс, p99 — до 156 мс, throughput падал до ~1 450 req/s, а у ~17% запросов вызов introspection к KC вообще срывался (timeout / отказ соединения под 50-way конкуренцией). jwks при тех же 50 воркерах отрабатывал без единой ошибки. Узкое место — именно introspection: локальная валидация масштабируется, online-проверка упирается в лимиты IdP и пула соединений к нему.
Тут — принципиальная тонкость HTTP-семантики, на которой легко ошибиться. Сорванный вызов introspection — это недоступность IdP, а не невалидный токен. Смешать их в один
401(как делала первая версия нашего же verifier) опасно: клиент, получив401, решит, что его токен протух, и пойдёт переавторизовываться — добавляя нагрузку на уже перегруженный IdP ровно тогда, когда ему нужно дать передышку. Правильный ответ на operational failure (timeout, 5xx, ошибка сети/парсинга) —503 Service UnavailableсRetry-Afterи отдельной метрикой, а401оставить для валидного ответа IdP, где токен отклонён по существу (active:falseили неверныйtyp). Стенд после ревью так и делает: недоступность IdP →503(проверено: при остановленном Keycloak валидный токен даёт503, а не401), а401/403— только по настоящему вердикту токена/роли. То есть те ~17% — это503-сигнал «IdP перегружен, повторите», а не «токены отклонены».
Вывод предметный, а не риторический: для высоконагруженного resource server дефолт — локальная JWKS-валидация. Она снимает с IdP всю нагрузку горячего пути и не делает Keycloak критической зависимостью каждого запроса. Introspection оставляют для случаев, где её плюс — мгновенная отзывность — реально нужен.
Когда introspection всё же нужен и как его удешевить
У локальной валидации есть обратная сторона, честно назовём её: отозвать локально проверяемый JWT досрочно нельзя. Если токен скомпрометирован или пользователя заблокировали, подписанный JWT остаётся валидным до своего exp — бэкенд о блокировке не знает, потому что не спрашивает IdP. Introspection этого недостатка лишена: она спрашивает Keycloak каждый раз и мгновенно увидит active: false после отзыва.
Отсюда — область, где introspection оправдана несмотря на цену:
- требуется мгновенный отзыв — операции с высокой ценой ошибки (платежи, админ-действия), где даже несколько минут жизни отозванного токена недопустимы;
- opaque-токены — если IdP выдаёт не JWT, а непрозрачный ссылочный токен, локально его проверить нечем: introspection — единственный способ узнать, что за ним стоит (сравнение opaque против JWT как моделей — в статье про модели сессий);
- особые требования комплаенса к централизованному контролю каждого доступа.
Если introspection всё же нужна, её цену можно снизить, не отказываясь от свойства отзыва:
- кэш результата на короткий TTL. Запоминать вердикт
active: trueна несколько секунд: это срезает нагрузку на IdP на порядок, а окно жизни отозванного токена остаётся коротким (равным TTL кэша). Компромисс «свежесть отзыва против нагрузки» настраивается длиной TTL. - гибрид: JWKS + короткий lifespan. Проверять подпись локально (быстро, без IdP), но выдавать короткоживущие access-токены — тогда отзыв «догоняет» токен за время его короткой жизни без всякой introspection. Часто это лучший баланс: скорость локальной валидации плюс приемлемое окно отзыва (см. следующий раздел).
- отдельный пул соединений к IdP. Как показал прогон на 50 воркерах, дефолтный пул к Keycloak — узкое место: под конкуренцией вызовы introspection срываются. Если introspection используется, ей нужен адекватно настроенный (и изолированный) HTTP-пул к IdP.
Тюнинг lifespans: свежесть отзыва против нагрузки
Раз мгновенный отзыв — единственное, чего лишена локальная валидация, время жизни токена становится главным рычагом баланса. Компромисс фундаментален:
- короткий access-token (минуты) — быстрая реакция на отзыв прав: даже без introspection отозванный доступ перестанет действовать через минуты, когда токен истечёт. Цена — чаще срабатывает refresh (обмен refresh-токена на новый access), то есть больше обращений к token endpoint Keycloak.
- длинный access-token (часы) — дешевле по числу обновлений, меньше трафика к IdP, но опаснее: отозванный доступ действует до истечения токена.
На стенде accessTokenLifespan в realm — 300 секунд (5 минут): разумный дефолт, при котором отозванный токен живёт не дольше пяти минут, а refresh не бьёт по token endpoint слишком часто. Настройки lifespans задаются на уровне realm (accessTokenLifespan, ssoSessionIdleTimeout, ssoSessionMaxLifespan) — где именно и с какими значениями, показано во второй статье.
Практический рецепт для большинства высоконагруженных бэкендов складывается из измеренного и разобранного выше: локальная JWKS-валидация как дефолт (0 обращений к IdP на горячем пути) плюс короткий access-token (окно отзыва в минуты) — и introspection только там, где мгновенный отзыв действительно критичен, желательно с кэшем на TTL. Это даёт скорость локальной проверки при контролируемом риске отозванного токена. Механику самого refresh на клиенте — тихое обновление токена на нескольких вкладках — разбирает отдельная статья цикла auth.
Роль из токена — не разрешение: мост к authz
Последний шаг resource server — извлечь роли из realm_access.roles — отвечает на вопрос «кто вошёл и в каких ролях». Но это не ответ на вопрос «что этому кому можно». Keycloak выдаёт роли; решение, можно ли обладателю роли admin удалить конкретную запись, — это уже авторизация доступа (authz), и живёт она в вашем приложении.
На стенде граница проходит наглядно: middleware проверяет hasRole("admin") для /admin — это простейший RBAC, «есть роль → пускаем». Реальные системы идут дальше роли: проверяют владельца ресурса, атрибуты запроса, отношения между сущностями. Роль из токена — это вход в модель авторизации, а не сама модель:
- разные модели enforcement — RBAC, ABAC, ReBAC — по-разному отвечают на «что можно»;
- вынести решение из кода в декларативные политики помогают движки OPA/Casbin/Cedar;
- fine-grained доступ уровня «этот пользователь может редактировать этот документ» решается в стиле Zanzibar/SpiceDB.
Разделение ответственности чёткое: Keycloak (authn) — «кто ты и в каких ролях», приложение (authz) — «что тебе можно». Resource server из этой статьи — стык между ними: он проверяет подлинность токена и достаёт роли, а дальше передаёт их в слой авторизации. Ту же границу мы отмечали в первой статье серии: IdP выдаёт роли, enforcement остаётся на вашей стороне.
Итог
- Бэкенд — это OIDC resource server. Его переносимая основа — стандартный стек OIDC (discovery + JWKS): принять access-JWT, проверить
alg/подпись/iss/aud/exp/nbf/typ, разграничить доступ. Сменить IdP на другой OIDC-совместимый можно без переписывания проверки — но не «бесплатно»: извлечение ролей завязано на Keycloak-специфичный claimrealm_access.roles, и при смене провайдера этот маппинг claims придётся адаптировать. Коды 200 / 401 / 403 (+ 503 при недоступности IdP в introspect) задают контракт; Go и Java дают идентичный результат на одних токенах. - Два пути валидации — с измеренной разницей. Локальная JWKS-проверка: 0 обращений к Keycloak на горячем пути, p50 1.18 мс, ~12 600 req/s. Introspection: ровно 1 обращение к IdP на запрос, p50 4.19 мс (×3.5), throughput ×3.2 хуже. С ростом конкуренции разрыв нелинейно растёт — на 50 воркерах introspection насыщала Keycloak, и ~17% вызовов introspection срывались (недоступность IdP → корректно
503, а не401), тогда как jwks держал нагрузку без единой ошибки. - Сбой introspection — это
503, а не401. Недоступность IdP (timeout/5xx/сеть) нельзя маскировать под «невалидный токен»: иначе клиенты бессмысленно переавторизуются под нагрузкой. Operational failure →503+Retry-After+ отдельная метрика;401— по валидному вердикту токена (active:falseили неверныйtyp— introspection тоже проверяет тип). - Дефолт для нагруженного бэкенда — локальная JWKS-валидация. Она снимает нагрузку с IdP и не делает Keycloak критической зависимостью каждого запроса. Кэш JWKS обновляется по
kidпри ротации ключей — без участия горячего пути. - Проверять — по порядку и полно.
alg(единственный ожидаемый) → подпись (JWKS поkid, ключ связан с алгоритмом) →iss→aud=backend→exp/nbf→typ=Bearer(отсекает подстановкуid_token/refresh — в Keycloak тип в claim, не в header), и только потом извлекатьrealm_access.roles. Go и Java проверяют это одинаково.KC_HOSTNAMEфиксирует issuer с обеих сторон, иначе валидный токен падает в 401. - Introspection — специальный инструмент. Для мгновенного отзыва и opaque-токенов; её цену снижают кэшем на короткий TTL или заменяют гибридом «локальная валидация + короткий access-token».
- Роль ≠ разрешение. Resource server отвечает «кто и в каких ролях», enforcement «что можно» — это authz на стороне приложения.
Дальше в серии — Keycloak под свой продукт: кастомные экраны логина и подключение внешних провайдеров (Яндекс/VK, разбор ЕСИА). Об этом — четвёртая статья серииготовится, с 15 сентября. А production-режим самого IdP — HA, БД, бэкапы, обновления — тема пятой, заключительной статьиготовится, с 16 сентября.
Источники
- Keycloak — Securing Applications and Services Guide (OIDC resource servers): https://www.keycloak.org/docs/latest/securing_apps/
- Keycloak — Server Administration Guide (token settings, lifespans, mappers): https://www.keycloak.org/docs/latest/server_admin/
- coreos/go-oidc — OIDC discovery для Go (в стенде используется только для discovery): https://github.com/coreos/go-oidc
- go-jose/go-jose — разбор и проверка JWT/JWS для Go (сама верификация access-токена): https://github.com/go-jose/go-jose
- Spring Security — OAuth2 Resource Server (JWT): https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
- RFC 8725 — JSON Web Token Best Current Practices (alg allowlist, typ, key-alg binding): https://www.rfc-editor.org/rfc/rfc8725
- RFC 7662 — OAuth 2.0 Token Introspection: https://www.rfc-editor.org/rfc/rfc7662
- RFC 7515 (JWS) и RFC 7517 (JWK): https://www.rfc-editor.org/rfc/rfc7517
- OpenID Connect Discovery 1.0: https://openid.net/specs/openid-connect-discovery-1_0.html
Комментарии