When one microservice calls another inside the cluster, it looks like a "safe" internal request. An isolated network, a VPC, closed ports — it seems there is nothing to worry about. That is a misconception: a compromised pod, a vulnerability in a container image, lateral movement across the cluster — these are all real scenarios. The "never trust, always verify" principle applies not only to external users, but to traffic inside the cluster as well.
That means every inter-service call must be authenticated. There are two main ways to do it.
mTLS — when a certificate replaces the password
Ordinary TLS (the one you see in a browser) is one-way: the server proves to the client who it is. mTLS (mutual TLS) is two-way: both the server and the client present certificates. That is why payment-service knows for certain that the request came from order-service and not from something else.
In Kubernetes this is handled by a Service Mesh (Istio, Linkerd). Each pod's sidecar proxy (Envoy) is automatically issued a unique SPIFFE-format certificate. All calls between pods are transparently encrypted and authenticated — the application does not even know about it.
order-service → [istio-proxy: cert=order-service-prod] ──mTLS──▶ [istio-proxy] → payment-service
If the receiving service needs to know exactly who called it (for authorization), Spring Security can read the CN of the client certificate:
@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();
}
}
Here the certificate's CN (order-service-prod) becomes the "user" name for Spring Security. No tokens, no passwords — the identity is baked into the transport.
Why mTLS is convenient:
- There is nothing to "forget" — the identity is passed automatically at the network layer, and a developer cannot skip a header.
- Istio rotates certificates every 24 hours without any team involvement.
- It works the same way for Java, Go, and Python — the language does not matter.
Where mTLS is harder:
- You need a Service Mesh infrastructure — that is a serious operational burden.
- In local development and tests without a Service Mesh you need a different approach.
Client Credentials Flow — a token instead of a certificate
If a Service Mesh is not available, people use the OAuth2 Client Credentials Flow. The idea: each service has its own client_id and client_secret, with which it obtains a short-lived token from an identity provider (Keycloak, Auth0, etc.), and then passes that token in the request header.
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: verifies the token signature, sees scope=payment:charge, allows it.
Spring Security takes over all token management. It is enough to describe the client registration in 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}
And configure an HTTP client with an interceptor:
@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 will request the token itself on the first call, cache it until it expires, and refresh it when it goes stale. No manual token-lifetime management is needed.
An important detail about scope. The right thing to do is to have a separate scope per operation: payment:charge, payment:refund, inventory:reserve. A single shared scope like service for everything is an antipattern: if the token leaks, the attacker gets unrestricted access to all operations.
Anonymous traffic — why it is dangerous
A common mistake is writing an HTTP client without authentication:
// Dangerous: any pod in the cluster can call 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
);
}
}
If one pod in the cluster is compromised through a vulnerability in its image, the attacker can call payment-service on its behalf. Without authentication, payment-service cannot distinguish a legitimate request from a fraudulent one.
The rule is simple: any HTTP client that calls another service must either go through the mTLS sidecar (then the network itself adds the identity) or use OAuth2ClientHttpRequestInterceptor (then the token is added automatically). Authentication is never added manually inside business logic — only at the level of client configuration.
Common mistakes
client_secret in code or in application.yml in plaintext. A service's secret must come through an environment variable or a secrets store (Vault, AWS Secrets Manager). The configuration should hold only a placeholder like ${ORDER_SERVICE_CLIENT_SECRET}.
One token for all operations between a pair of services. It seems convenient — grab one token and use it for everything. But then a leak of that token opens up all operations at once. The scope should be narrow and specific.
mTLS only for external traffic. Some people enable mTLS only at the cluster's edge and leave everything open on the inside. The zero-trust principle applies everywhere — including traffic between your own services.
Manually adding an Authorization header in a request handler. This mixes authentication infrastructure with business logic. An interceptor at the HTTP-client level is the only correct place.
In short
- Traffic between services inside a cluster is not "safe by default." Every call must be authenticated.
- mTLS via a Service Mesh: the certificate = identity, and the Spring application does not touch it. Good where you have Istio/Linkerd.
- Client Credentials Flow: a service obtains a token from an identity provider and passes it in
Authorization: Bearer. Spring Security caches and refreshes the token automatically. - The scope should be per operation (
payment:charge), not per service as a whole. client_secret— only through an environment variable or a secrets store, never in code.- Authentication goes on the HTTP client via an interceptor, not inside business logic.
Further reading
- JWT validation — how the receiving service verifies the token from the caller.
- PII and secrets — storing
client_secretvia Vault. - Where each check goes — the
systemrole for S2S requests.