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

Когда один микросервис вызывает другой внутри кластера, это выглядит как «безопасный» внутренний запрос. Изолированная сеть, VPC, закрытые порты — кажется, что бояться нечего. Это ошибочное представление: взломанный под, уязвимость в образе контейнера, горизонтальное перемещение по кластеру — всё это реальные сценарии. Принцип «не доверяй, проверяй» применяется не только к внешним пользователям, но и к трафику внутри кластера.

Значит, каждый межсервисный вызов должен быть аутентифицирован. Для этого есть два основных способа.

Оба способа отвечают на вопрос «кто звонит», но второй — токен со scope — отвечает ещё и на «что ему можно». Ниже: секрет order-service утёк, и вся разница в последствиях — в ширине выданного этому сервису scope.

Order оформляет заказ и зовёт Payment /charge grant_type=client_credentialsclient_id=order-service-prod, expires_in=3600 секрет order-service утёкатакующий сам получает токен у провайдераподпись настоящая, Payment её примет scope = paymentодин scope на весь Payment /charge — scope подошёл, 200/refund — тот же scope, 200деньги ушли на чужую карту scope = payment:chargeправо на одну операцию /charge — scope совпал, 200/refund — нужен payment:refund403: этого scope в токене нет подпись одинаково верна в обоих случаях — границу ущерба задаёт ширина scopeу Order 3 права из 5 в таблице вызовов, payment:refund среди них нет

Утёкший секрет даёт атакующему настоящий токен — проверка подписи тут не спасает. Разницу делает ширина scope: общий payment открывает и возвраты, payment:charge — только списание.

Обязательно

mTLS — когда сертификат заменяет пароль

Сосед по кластеру может позвонить в payment-service и представиться order-service: в HTTP-запросе нет ничего, что доказывало бы, кто его отправил. Обычный TLS (тот, что вы видите в браузере) здесь не поможет — он односторонний: сервер доказывает клиенту, кто он, а клиент остаётся анонимным. mTLS (mutual TLS) делает проверку двусторонней: сертификат предъявляют оба, и payment-service точно знает, что запрос пришёл именно от order-service, а не от чего-то постороннего.

В Kubernetes это делает Service Mesh (Istio, Linkerd). Каждому поду sidecar-прокси (Envoy) автоматически выдаётся уникальный сертификат SPIFFE-формата. Все вызовы между подами прозрачно шифруются и аутентифицируются — приложение об этом даже не знает.

order-service istio-proxy сертификат order-service-prod istio-proxy проверяет сертификат payment-service

Сервисы не занимаются шифрованием сами: каждый говорит со своим прокси по локальной сети, а между прокси идёт mTLS. Подмена вызывающего не пройдёт — сертификат выдан конкретному сервису.

Если приложение само завершает защищённое соединение — сеткой вы не пользуетесь, сертификаты выпускаете сами, — Spring Security умеет достать имя из поля CN клиентского сертификата:

@Configuration
@ConditionalOnProperty(name = "security.mtls.enabled", havingValue = "true")
public class MtlsSecurityConfig {

    @Bean
    SecurityFilterChain internalApi(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/internal/**")
            .x509(x509 -> x509
                .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
                .userDetailsService(serviceUserDetails()))
            .authorizeHttpRequests(authz -> authz.anyRequest().authenticated())
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .build();
    }

    @Bean
    UserDetailsService serviceUserDetails() {
        return username -> User.builder()
            .username(username)
            .password("")
            .authorities("ROLE_system")
            .build();
    }
}

Здесь CN сертификата (order-service-prod) становится именем «пользователя» для Spring Security. Никаких токенов, никаких паролей — личность зашита в транспорт.

Две строки в конце добавлены не для красоты. Защита от подделки межсайтовых запросов в Spring Security включена по умолчанию, и без csrf.disable() первый же POST /internal/… от соседнего сервиса получит 403: токена защиты у машины взять неоткуда, форму она не открывала. По той же причине выключают и сессии — сервису не нужна память между запросами, каждый вызов приходит со своим сертификатом.

А вот в сервисной сетке этот код не применим вовсе, и это важная оговорка. Во-первых, соединение расшифровывает соседний контейнер-посредник, а приложению отдаёт уже обычный запрос — сертификата оно не увидит. Во-вторых, в сертификатах сетки личность лежит не в CN: Istio выписывает их в формате SPIFFE, и имя сервиса записано в поле альтернативных имён как адрес вида spiffe://cluster.local/ns/prod/sa/order-service, а CN там обычно пустой. Регулярное выражение по CN на таком сертификате не найдёт ничего. Личность отправителя посредник кладёт в заголовок, и брать её нужно оттуда, обязательно закрыв прямой доступ к приложению в обход посредника.

Почему mTLS удобен:

  • Нечего «забыть» — identity передаётся автоматически на уровне сети, разработчик не может пропустить заголовок.
  • Сервисная сетка (например, Istio) сама выдаёт и обновляет сертификаты, команде за этим следить не нужно.
  • Работает одинаково для Java, Go, Python — язык не важен.

Где mTLS сложнее:

  • Нужна Service Mesh инфраструктура — это серьёзная операционная нагрузка.
  • В локальной разработке и тестах без Service Mesh нужен другой подход.

Client Credentials Flow — токен вместо сертификата

Если Service Mesh недоступен, используют OAuth2 Client Credentials Flow. Идея: каждый сервис имеет свои client_id и client_secret, с которыми получает короткоживущий токен у провайдера идентичности (Keycloak, Auth0 и т.д.), а потом передаёт этот токен в заголовке запроса.

ШагЧто уходитЧто приходит в ответ
order-service → IdPPOST /oauth/token с grant_type=client_credentials, client_id=order-service-prod, client_secret=…, scope=payment:charge{ "access_token": "…", "expires_in": 3600 } — токен живёт час
order-service → payment-servicePOST /charge с заголовком Authorization: Bearer <token>payment-service проверил подпись токена, увидел scope=payment:charge и разрешил списание

Час жизни здесь — то, что обычно отдаёт провайдер по умолчанию, а не рекомендация. Для машинного токена час — это долго: утёкшим токеном можно пользоваться всё оставшееся время, и отозвать его нельзя. Сократить срок до нескольких минут стоит, но главной мерой это не сделаешь: пока токен жив, он всё равно открывает ровно то, что записано в его scope. Размер ущерба задаёт ширина scope, а срок жизни лишь укорачивает окно. Поэтому сначала сужают права, и только потом крутят минуты.

Spring Security берёт всё управление токенами на себя. Достаточно описать регистрацию клиента в application.yml:

spring:
  security:
    oauth2:
      client:
        registration:
          payment-service:                       # куда ходим
            client-id: order-service-prod        # кто ходит — это мы
            client-secret: ${ORDER_SERVICE_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: payment:charge
        provider:
          payment-service:
            token-uri: ${IDP_TOKEN_URI}

Про именование стоит сказать отдельно, потому что на нём спотыкаются: payment-service здесь — название регистрации, то есть ярлык для набора настроек «как ходить в сервис платежей». Именно его потом передают в код. А client-id: order-service-prod — это учётная запись вызывающего, наша собственная. Читать clientRegistrationIdResolver(req -> "payment-service") надо как «для этого запроса возьми настройки похода в payment-service», а не как «мой идентификатор — payment-service».

И настроить HTTP-клиент с перехватчиком:

@Configuration
@RequiredArgsConstructor
public class PaymentClientConfig {

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository registrations,
        OAuth2AuthorizedClientService clients
    ) {
        var provider = OAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build();
        var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    RestClient paymentRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        var interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
        interceptor.setClientRegistrationIdResolver(req -> "payment-service");
        return RestClient.builder()
            .baseUrl("https://payment-service.internal")
            .requestInterceptor(interceptor)
            .build();
    }
}

OAuth2ClientHttpRequestInterceptor сам запросит токен при первом обращении, закеширует его до истечения и обновит, когда тот устареет. Никакого ручного управления жизнью токена не нужно. Один момент про версии: и сам перехватчик, и setClientRegistrationIdResolver появились в Spring Security 6.4 (Spring Boot 3.4). На 6.3 и раньше этот пример не соберётся — там для RestClient придётся писать свой перехватчик поверх OAuth2AuthorizedClientManager.

Третий способ: сетевые правила и учётные записи кластера

Выбор «сетка или токен» описан выше как выбор из двух, но на практике в Kubernetes почти всегда есть третий слой, и его берут вместе с любым из первых двух.

Сетевые правила отвечают на вопрос «кто вообще может открыть соединение». NetworkPolicy разрешает входящий трафик к payment-service только от подов с меткой app=order-service и из нужного пространства имён, а всё остальное отбрасывает на уровне сети. Это резко уменьшает цену ошибки в приложении: даже забытая проверка токена не поможет поду, который не может до вас дотянуться. Важно знать ограничение: правила ничего не говорят о том, от чьего имени идёт запрос, и не переживают взлом того самого пода, которому доступ разрешён. Поэтому они дополняют аутентификацию, а не заменяют её. И ещё одно: политики работают, только если сетевой плагин кластера их поддерживает и в пространстве имён есть правило «запретить всё» по умолчанию, иначе разрешающие правила ничего не ограничивают.

Учётные записи сервисов дают ту самую личность, которой нет у сетевых правил. У каждого пода есть ServiceAccount, и кластер монтирует внутрь токен этой учётной записи; современный вариант — проецируемый токен с указанной аудиторией и коротким сроком жизни, который кластер сам обновляет:

volumes:
  - name: payment-token
    projected:
      sources:
        - serviceAccountToken:
            audience: payment-service
            expirationSeconds: 3600
            path: token

Такой токен вызывающий сервис прикладывает как Authorization: Bearer, а принимающий проверяет его либо через API кластера, либо как обычный JWT — кластер публикует свои открытые ключи, и payment-service настраивается на них так же, как на Keycloak. Аудитория здесь важна: без неё токен, выданный для API кластера, подошёл бы к любому сервису.

Когда что брать. Если провайдер идентичности уже стоит и сервисам всё равно нужны области доступа — берут client credentials, а сетевые правила ставят как второй контур. Если сервисов немного, а провайдера нет, учётные записи кластера закрывают вопрос без нового звена в цепочке. А сервисная сетка выигрывает там, где сервисов десятки и никому не хочется править код: сертификаты, обновление и правила доступа уезжают в инфраструктуру целиком.

Как это ложится на маркетплейс

Примеры выше не выдуманы: order-service и payment-service — это два из шести сервисов кейса маркетплейса. Разложим по ним все внутренние вызовы и нужные им права.

Кто зовётКогоЗачемScope
OrderCatalogзарезервировать остаток при оформленииcatalog:reserve
OrderPaymentсписать деньги за заказpayment:charge
BackofficePaymentвернуть деньги по решению спораpayment:refund
PaymentOrderсообщить, что оплата прошлаorder:confirm
OrderNotificationотправить письмо продавцу о новой продажеnotification:send

Видно, зачем scope дробят по операциям. У Order есть право списать деньги, но нет права вернуть — возврат оформляет Backoffice по решению оператора. Если бы обоим выдали общий scope payment, утёкший секрет сервиса заказов открывал бы и возвраты, а это прямой путь к выводу денег.

Второй вопрос, который встаёт на первом же вызове: что делать с пользователем. Покупатель нажал «Оформить», Order пошёл в Payment — чей токен туда ехать? Вариантов два, и они не равнозначны:

  • Проброс токена пользователя. Payment видит, кто именно платит, и может проверить владение. Но токен покупателя расползается по всем сервисам цепочки, и любой из них может им воспользоваться от его имени.
  • Свой токен сервиса, а идентификатор пользователя — в теле запроса. Payment знает, что зовёт именно Order (по client credentials), а покупателя видит как поле buyerId в команде. Проверку «заказ принадлежит этому покупателю» при этом делает Order — тот, у кого есть данные заказа.

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

Из этого следует правило, которое Payment обязан соблюдать: полю buyerId из запроса в вопросах денег не верят на слово. Сумма и получатель выплаты берутся из собственных записей Payment, а не из входящей команды — иначе взломанный Order сможет прислать любую сумму и любого получателя.

И отдельно про Notification. Он ничего не решает и никем не владеет — просто отправляет то, что попросили. Но право notification:send всё равно выдают адресно: сервис, который может слать письма от имени площадки, — удобный инструмент для рассылки чего угодно, если секрет утечёт.

А что на принимающей стороне

Всё, что описано выше, — это половина работы. Узкий scope сам по себе не значит ничего: он проверяется не у того, кто токен получил, а у того, кто его принял. Если в payment-service нет ни строчки про проверку — туда пройдёт токен с любым scope, и вся затея с дроблением прав ничего не даст.

Bearer token payment-service от order-service 401 подпись ключ провайдера не подошёл 401 issuer iss чужого провайдера 401 audience aud не payment-service 403 scope нет payment:charge 200 charge все четыре проверки прошли

Один входящий токен и четыре разных отказа на принимающей стороне: смотрите, какая проверка срабатывает и каким кодом отвечает.

Минимум на стороне payment-service — две вещи. Первая: описать, чьи токены он вообще принимает:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${IDP_ISSUER_URI}
          audiences: payment-service    # токен должен быть выписан именно нам

Вторая: потребовать конкретный scope на конкретном пути:

@Bean
SecurityFilterChain internalApi(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/charge", "/refund")
        .authorizeHttpRequests(authz -> authz
            .requestMatchers("/charge").hasAuthority("SCOPE_payment:charge")
            .requestMatchers("/refund").hasAuthority("SCOPE_payment:refund")
            .anyRequest().denyAll())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .build();
}

Spring Security сам разбирает claim scope из токена и складывает каждое значение в authority с приставкой SCOPE_ — поэтому в коде пишут hasAuthority("SCOPE_payment:charge"), а не hasRole.

Отдельно про audiences. В токене есть поле aud — кому он выписан. Без его проверки происходит вот что: order-service попросил у провайдера токен для сервиса уведомлений, notification-service этот токен получил — и может предъявить его в payment-service. Подпись верна, срок не истёк, формально всё в порядке. Именно поэтому принимающая сторона проверяет не только подпись, но и то, что токен адресован ей.

Важная деталь про scope. Правильно делать отдельный scope на каждую операцию: payment:charge, payment:refund, inventory:reserve. Один общий scope вроде service для всего — антипаттерн: если токен утечёт, атакующий получает неограниченный доступ ко всем операциям.

Что ломается: провайдер, сроки и смена секрета

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

Провайдер недоступен. Если за токеном ходят на каждый вызов, недоступный Keycloak останавливает всю внутреннюю связность сразу — один сервис роняет все. Лечится это тем, что токен кэшируют: Spring хранит выданный токен в OAuth2AuthorizedClientManager и запрашивает новый заранее, считая токен просроченным за минуту до exp (это настройка setClockSkew у провайдера client credentials). Тогда падение провайдера на пять минут проходит незамеченным. Полезно и обратное: у принимающей стороны открытые ключи тоже закэшированы, поэтому проверять подпись она продолжает, даже пока провайдер лежит. Что не надо делать — так это разрешать вызов без токена, «раз провайдер недоступен»: аварийный режим, открывающий доступ, страшнее самой аварии.

Токен истёк по дороге. Срок жизни сервисного токена — минуты, а повтор после таймаута может случиться через полминуты. Возможен и сдвиг часов между машинами. Поэтому на ответ 401 клиенту разрешают ровно одну попытку: выбросить токен из кэша, получить новый, повторить запрос. Именно одну — бесконечные повторы на 401 превращаются в атаку на собственный провайдер. Повторять 403 бессмысленно вовсе: это отказ по правам, новый токен его не изменит.

Смена секрета. Ротация client_secret ломает связность, если сделать её одним движением: старый секрет отозвали, поды ещё работают со старым, новые токены получить нельзя, а кэшированные истекают один за другим. Поэтому нужны два действующих секрета одновременно. В Keycloak это отдельная политика ротации секрета клиента: после смены прежний остаётся действующим заданное время. Порядок такой: выписать новый секрет → положить в хранилище и перезапустить поды по одному → убедиться, что ни один под не использует старый → отозвать старый. Секрет читается при старте приложения, поэтому без перезапуска или перечитывания конфигурации новое значение в работу не попадёт.

Из этого же вытекает, чего не надо делать: получать токен внутри бизнес-логики, «чтобы наверняка свежий». Токен — забота клиентского слоя, и все три случая выше решаются там один раз для всех вызовов.

Анонимный трафик — почему это опасно

Частая ошибка — написать HTTP-клиент без аутентификации:

// Опасно: любой под в кластере может позвонить в payment-service
@Component
public class PaymentClient {
    private final RestTemplate restTemplate;

    public Receipt charge(Long orderId, Money amount) {
        return restTemplate.postForObject(
            "http://payment-service/charge",
            new ChargeRequest(orderId, amount),
            Receipt.class
        );
    }
}

Если один под в кластере взломан через уязвимость в образе, атакующий может вызывать payment-service от его имени. Без аутентификации payment-service не отличит легитимный запрос от мошеннического.

Правило простое: любой HTTP-клиент, вызывающий другой сервис, должен либо идти через mTLS-sidecar (тогда сеть сама добавит identity), либо использовать OAuth2ClientHttpRequestInterceptor (тогда токен добавится автоматически). Аутентификация никогда не добавляется вручную внутри бизнес-логики — только на уровне клиентской конфигурации.

Локальная разработка и тесты

Оба способа выше опираются на инфраструктуру, которой на ноутбуке нет: сервисной сетки нет, а поднимать провайдер идентичности ради одного сервиса не хочется. Отсюда соблазн, которым закрываются очень многие: профиль local с permitAll() или с выключенной защитой. Опасность не в самом профиле, а в том, что такой профиль однажды уезжает в прод — переменной окружения, забытым SPRING_PROFILES_ACTIVE, копией конфигурации. Плюс проверка доступа перестаёт работать там, где её как раз и стоило бы проверять: локально.

Что делают вместо этого.

Провайдер в контейнере. Keycloak в режиме start-dev с заранее подготовленным realm (экспорт в realm.json, который импортируется при старте) поднимается за секунды и в compose-файле, и в интеграционных тестах через Testcontainers. Сервис при этом работает с настоящим потоком client credentials — той же настройкой, что в проде, с другим адресом.

Подмена на границе, а не отмена проверки. Если провайдер в тесте не нужен, подменяют одну точку: JwtDecoder на декодер с локальным ключом, которым тест сам подписывает токены, или OAuth2AuthorizedClientManager на выдающий фиксированный токен. Конфигурация правил доступа при этом остаётся настоящей, и тест продолжает проверять именно её. Для запросов из теста у Spring есть готовое: jwt() и @WithMockUser из spring-security-test подставляют личность без всякого провайдера.

Взаимную проверку сертификатов локально не воспроизводят. Держат два варианта клиента: в кластере личность даёт сетка, локально — обычный HTTP плюс тот же токен или фиксированный заголовок, который в кластере срезается на границе. Разница описана в конфигурации, а не в коде: if (local) внутри бизнес-логики — та же дверь в прод, только незаметнее.

Отдельно стоит завести тест, который проверяет обратное: что защита включена. Запрос без токена к внутренней ручке отвечает 401, а не 200. Такой тест ловит и профиль с permitAll(), и случайно отключённую конфигурацию — самую частую причину открытых внутренних ручек.

Частые ошибки

client_secret в коде или в application.yml в открытом виде. Секрет сервиса должен приходить через переменную окружения или хранилище секретов (Vault, AWS Secrets Manager). В конфигурации — только плейсхолдер вроде ${ORDER_SERVICE_CLIENT_SECRET}.

Один токен на все операции между парой сервисов. Кажется удобным — взял один токен и используешь для всего. Но тогда утечка этого токена открывает все операции сразу. Scope должен быть узким и конкретным.

mTLS только для внешнего трафика. Некоторые включают mTLS только на входе в кластер, а внутри оставляют всё открытым. Принцип нулевого доверия применяется везде — включая трафик между своими сервисами.

Ручное добавление Authorization заголовка в обработчике запроса. Это смешивает аутентификационную инфраструктуру с бизнес-логикой. Перехватчик на уровне HTTP-клиента — единственное правильное место.

Дополнительно: при первом чтении можно пропустить

Глубже: от имени пользователя: обмен токена по RFC 8693расширенное

Статья оставляет открытым вопрос, который встаёт в первый же день: сервис заказов вызывает сервис оплаты, и оплата должна знать, какой покупатель платит. Пробросить дальше токен покупателя нельзя: он выписан для сервиса заказов (aud), у него права покупателя целиком, а не только на этот платёж, и его срок жизни не ваш. Ходить от имени сервиса по client credentials можно, но тогда оплата верит сервису заказов на слово, что покупатель именно этот.

Стандартный ответ называется обменом токена, RFC 8693. Сервис заказов идёт на сервер аутентификации с токеном покупателя и просит выписать другой: для аудитории payment-service, с нужным набором областей и с пометкой, кто действует от чьего имени:

POST /realms/shop/protocol/openid-connect/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=order-service&client_secret=...
&subject_token=<access_token покупателя>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=payment-service
&scope=payments:create

В ответ приходит токен, у которого sub это покупатель, aud это сервис оплаты, области только запрошенные, а в claim act записан сервис заказов как действующая сторона. Сервис оплаты проверяет его как любой другой токен, видит покупателя и видит, через кого он пришёл; журнал получает обоих. Это называют делегированием, и оно отличается от имперсонации, при которой следов посредника в токене нет.

В Keycloak обмен токена долго был предварительной возможностью за флагом, а с версии 26.2 стандартный обмен включается на клиенте («Standard token exchange») без флагов; серверу аутентификации при этом нужно разрешение, что order-service вправе просить токены для payment-service, иначе любой клиент мог бы выписать себе токен для чего угодно. В Spring обмен делают через OAuth2AuthorizedClientManager с провайдером token-exchange (Spring Security 6.3 и новее), и полученный токен кладут в исходящий вызов тем же RestClient.

Правило выбора. Внутренняя цепочка, где пользователь не важен (пересчёт остатков, уведомления): client credentials. Цепочка, где нижний сервис принимает решение по правам пользователя (оплата, доступ к документу): обмен токена. Проброс исходного токена только там, где сервисы это одно приложение с общей аудиторией, и это осознанное исключение, а не умолчание.

Коротко

  • Трафик между сервисами внутри кластера — не «безопасный по умолчанию». Каждый вызов нужно аутентифицировать.
  • mTLS через Service Mesh: сертификат = identity, Spring-приложение не трогает. Хорошо там, где есть Istio/Linkerd.
  • Client Credentials Flow: сервис получает токен от провайдера идентичности и передаёт его в Authorization: Bearer. Spring Security кеширует и обновляет токен автоматически.
  • Scope должен быть на операцию (payment:charge), не на сервис целиком, и работает он только если принимающая сторона его проверяет: hasAuthority("SCOPE_…") на пути плюс проверка aud, иначе пройдёт любой токен, выписанный кому угодно.
  • client_secret — только через переменную окружения или хранилище секретов, никогда в коде; аутентификация вешается на HTTP-клиент перехватчиком, не внутри бизнес-логики.
  • Когда нижний сервис должен знать пользователя, токен не пробрасывают, а обменивают по RFC 8693: новая аудитория, узкие области, act с посредником; в Keycloak 26.2 это штатная настройка клиента.
  • Токен кэшируют и обновляют заранее, за минуту до exp: тогда недоступный провайдер не роняет внутреннюю связность. На 401 — ровно один повтор со свежим токеном, на 403 — ни одного.
  • Ротация секрета требует двух действующих значений сразу: новый секрет → перезапуск подов по одному → отзыв старого.
  • В кластере сетевые правила и учётные записи сервисов дают третий слой: правила отвечают «кто может дотянуться», проецируемый токен с аудиторией — «кто это».
  • Локально не выключают защиту, а подменяют одну точку: JwtDecoder или менеджер клиента, плюс тест «без токена → 401».

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