Когда один микросервис вызывает другой внутри кластера, это выглядит как «безопасный» внутренний запрос. Изолированная сеть, VPC, закрытые порты — кажется, что бояться нечего. Это ошибочное представление: взломанный под, уязвимость в образе контейнера, горизонтальное перемещение по кластеру — всё это реальные сценарии. Принцип «не доверяй, проверяй» применяется не только к внешним пользователям, но и к трафику внутри кластера.
Значит, каждый межсервисный вызов должен быть аутентифицирован. Для этого есть два основных способа.
mTLS — когда сертификат заменяет пароль
Обычный TLS (тот, что вы видите в браузере) — односторонний: сервер доказывает клиенту, кто он. mTLS (mutual TLS) — двусторонний: и сервер, и клиент предъявляют сертификаты. Поэтому payment-service точно знает, что запрос пришёл именно от order-service, а не от чего-то постороннего.
В Kubernetes это делает Service Mesh (Istio, Linkerd). Каждому поду sidecar-прокси (Envoy) автоматически выдаётся уникальный сертификат SPIFFE-формата. Все вызовы между подами прозрачно шифруются и аутентифицируются — приложение об этом даже не знает.
order-service → [istio-proxy: cert=order-service-prod] ──mTLS──▶ [istio-proxy] → payment-service
Если на стороне принимающего сервиса нужно знать, кто именно обратился (для авторизации), 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())
.build();
}
@Bean
UserDetailsService serviceUserDetails() {
return username -> User.builder()
.username(username)
.password("")
.authorities("ROLE_system")
.build();
}
}
Здесь CN сертификата (order-service-prod) становится именем «пользователя» для Spring Security. Никаких токенов, никаких паролей — личность зашита в транспорт.
Почему mTLS удобен:
- Нечего «забыть» — identity передаётся автоматически на уровне сети, разработчик не может пропустить заголовок.
- Istio ротирует сертификаты каждые 24 часа без участия команды.
- Работает одинаково для 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 ──▶ IdP: POST /oauth/token
grant_type=client_credentials
client_id=order-service-prod
client_secret=...
scope=payment:charge
◀── IdP: { "access_token": "...", "expires_in": 3600 }
order-service ──▶ payment-service: POST /charge
Authorization: Bearer <token>
payment-service: проверяет подпись токена, видит scope=payment:charge, разрешает.
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}
И настроить 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 сам запросит токен при первом обращении, закеширует его до истечения и обновит, когда тот устареет. Никакого ручного управления жизнью токена не нужно.
Важная деталь про scope. Правильно делать отдельный scope на каждую операцию: payment:charge, payment:refund, inventory:reserve. Один общий scope вроде service для всего — антипаттерн: если токен утечёт, атакующий получает неограниченный доступ ко всем операциям.
Анонимный трафик — почему это опасно
Частая ошибка — написать 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 (тогда токен добавится автоматически). Аутентификация никогда не добавляется вручную внутри бизнес-логики — только на уровне клиентской конфигурации.
Частые ошибки
client_secret в коде или в application.yml в открытом виде. Секрет сервиса должен приходить через переменную окружения или хранилище секретов (Vault, AWS Secrets Manager). В конфигурации — только плейсхолдер вроде ${ORDER_SERVICE_CLIENT_SECRET}.
Один токен на все операции между парой сервисов. Кажется удобным — взял один токен и используешь для всего. Но тогда утечка этого токена открывает все операции сразу. Scope должен быть узким и конкретным.
mTLS только для внешнего трафика. Некоторые включают mTLS только на входе в кластер, а внутри оставляют всё открытым. Принцип нулевого доверия применяется везде — включая трафик между своими сервисами.
Ручное добавление Authorization заголовка в обработчике запроса. Это смешивает аутентификационную инфраструктуру с бизнес-логикой. Перехватчик на уровне HTTP-клиента — единственное правильное место.
Коротко
- Трафик между сервисами внутри кластера — не «безопасный по умолчанию». Каждый вызов нужно аутентифицировать.
- mTLS через Service Mesh: сертификат = identity, Spring-приложение не трогает. Хорошо там, где есть Istio/Linkerd.
- Client Credentials Flow: сервис получает токен от провайдера идентичности и передаёт его в
Authorization: Bearer. Spring Security кеширует и обновляет токен автоматически. - Scope должен быть на операцию (
payment:charge), не на сервис целиком. client_secret— только через переменную окружения или хранилище секретов, никогда в коде.- Аутентификация на HTTP-клиент через перехватчик, не внутри бизнес-логики.
Что почитать дальше
- JWT validation — как принимающий сервис проверяет токен от вызывающего.
- PII и секреты — хранение
client_secretчерез Vault. - Где какая проверка — роль
systemдля S2S запросов.