Service-to-Service Authentication

Options

ApproachHow it worksProsCons
OAuth2 Client CredentialsThe service logs in to Keycloak with its own client ID/secret and gets a token with its own scopesStandard, fine-grained scopes, works with Spring SecurityToken fetch and caching to manage (Spring does it)
mTLSBoth sides present certificates; identity = certificateEncrypts traffic and authenticates the machineCertificate issuing and rotation
Service mesh (Istio, Linkerd)Sidecar proxies do mTLS automatically; policies in YAMLNo code changesExtra infrastructure to run and learn
Shared API key / static headerA secret string in a headerVery simpleNo expiry, hard to rotate; avoid

Client Credentials with RestClient

order-service

application.yml

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            token-uri: http://keycloak:8180/realms/shopease/protocol/openid-connect/token
        registration:
          payment-api:
            provider: keycloak
            client-id: order-service
            client-secret: ${ORDER_SERVICE_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: payments:write
@Configuration
public class PaymentClientConfig {

    // Works outside an HTTP request too (Kafka listeners, schedulers)
    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
                                                          OAuth2AuthorizedClientService clients) {
        var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
        manager.setAuthorizedClientProvider(
                OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());
        return manager;
    }

    @Bean
    RestClient paymentClient(RestClient.Builder builder, OAuth2AuthorizedClientManager manager) {
        var oauth = new OAuth2ClientHttpRequestInterceptor(manager);       // Spring Security 6.4+
        oauth.setClientRegistrationIdResolver(request -> "payment-api");
        return builder.baseUrl("http://payment-service").requestInterceptor(oauth).build();
    }
}

Example

What happens on the wire (the token is cached until it nearly expires):

POST http://keycloak:8180/realms/shopease/protocol/openid-connect/token
grant_type=client_credentials&scope=payments:write   (Basic auth: order-service:****)

POST http://payment-service/api/payments
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzZXJ2aWNlLWFjY291bnQtb3JkZXItc2VydmljZSIs...

Example output

HTTP/1.1 200 OK
{"access_token":"eyJhbGciOiJSUzI1NiJ9...","expires_in":300,"token_type":"Bearer","scope":"payments:write"}

HTTP/1.1 201 Created
{"paymentId":"pay_NqX81kLm2","status":"CAPTURED"}

mTLS with Spring Boot SSL Bundles

payment-service

Spring Boot 3.1+ SSL bundles configure certificates in one place for the server and for clients:

application.yml

server:
  port: 8443
  ssl:
    bundle: internal
    client-auth: need                  # callers MUST present a valid certificate
spring:
  ssl:
    bundle:
      pem:
        internal:
          keystore:
            certificate: file:/etc/certs/payment-service.crt
            private-key: file:/etc/certs/payment-service.key
          truststore:
            certificate: file:/etc/certs/shopease-internal-ca.crt

Tip: In Kubernetes, most teams let a service mesh (Istio, Linkerd) handle mTLS and keep OAuth2 tokens for authorization (scopes/roles). Certificates prove which service is calling; tokens say what it may do.