How to secure service-to-service authentication in Spring Boot microservices
Problem
When I deployed my microservices architecture to production, I got this access denied error:
2026-03-09 14:32:15 ERROR [order-service] c.e.OrderController : Access denied to inventory serviceorg.springframework.security.access.AccessDeniedException: Access is deniedThe order service couldn’t call the inventory service’s internal API. I realized I had user authentication working, but I never implemented service-to-service authentication.
Environment
- Spring Boot 3.2
- Spring Security 6.2
- Java 21
- Multiple microservices (order, inventory, payment, notification)
What happened?
I was building an e-commerce platform with microservices. Each service needed to call the others:
- Order service calls inventory to check stock
- Payment service calls order to process payment
- Notification service calls order to send emails
I had JWT authentication working for user-to-service calls. The frontend gets a JWT token and uses it to call the APIs.
@Configuration@EnableWebSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/**").authenticated() .anyRequest().permitAll()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build(); }}When the order service tried to call the inventory service’s internal endpoint:
@Servicepublic class OrderService {
public void createOrder(OrderRequest request) { // Check inventory InventoryInfo stock = restTemplate.getForObject( "https://inventory-service/api/internal/stock/" + request.getProductId(), InventoryInfo.class ); }}The request failed because it had no authentication. The inventory service rejected it with 401 Unauthorized.
I tried passing the user’s JWT token along:
public void createOrder(OrderRequest request, String userJwt) { HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + userJwt);
HttpEntity<?> entity = new HttpEntity<>(headers);
InventoryInfo stock = restTemplate.exchange( "https://inventory-service/api/internal/stock/" + request.getProductId(), HttpMethod.GET, entity, InventoryInfo.class ).getBody();}This worked, but it’s wrong. The user’s token proves the user’s identity, not the service’s identity. An admin user could call the internal endpoint directly and bypass business logic.
┌──────────┐ User JWT ┌──────────────┐│ Frontend │ ───────────────→ │ Order Service│└──────────┘ └──────────────┘ │ │ User JWT (WRONG!) ▼ ┌──────────────┐ │Inventory API │ ← Admin could call directly! └──────────────┘I needed a way for services to authenticate as themselves, not as users.
How to solve it?
I tried three approaches, starting with the simplest.
Approach 1: JWT-Based Service Authentication
I created a separate authentication mechanism for services. Each service has a unique ID and secret key.
First, I created a service authentication filter:
@Componentpublic class ServiceAuthenticationFilter extends OncePerRequestFilter {
private final JwtDecoder jwtDecoder; private final Map<String, String> serviceSecrets = Map.of( "order-service", "${ORDER_SERVICE_SECRET}", "payment-service", "${PAYMENT_SERVICE_SECRET}", "notification-service", "${NOTIFICATION_SERVICE_SECRET}" );
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String serviceToken = request.getHeader("X-Service-Token");
if (serviceToken != null) { try { Jwt jwt = jwtDecoder.decode(serviceToken); String serviceId = jwt.getSubject();
if (serviceSecrets.containsKey(serviceId)) { ServiceAuthentication authentication = new ServiceAuthentication(serviceId, getAuthorities(serviceId)); SecurityContextHolder.getContext() .setAuthentication(authentication); } } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } }
filterChain.doFilter(request, response); }
private Set<GrantedAuthority> getAuthorities(String serviceId) { return switch (serviceId) { case "order-service" -> Set.of( new SimpleGrantedAuthority("READ_INVENTORY"), new SimpleGrantedAuthority("WRITE_ORDERS") ); case "payment-service" -> Set.of( new SimpleGrantedAuthority("PROCESS_PAYMENTS") ); case "notification-service" -> Set.of( new SimpleGrantedAuthority("SEND_EMAILS") ); default -> Set.of(); }; }}Then I created a custom authentication class:
public class ServiceAuthentication implements Authentication {
private final String serviceId; private final Set<GrantedAuthority> authorities; private boolean authenticated = true;
public ServiceAuthentication(String serviceId, Set<GrantedAuthority> authorities) { this.serviceId = serviceId; this.authorities = authorities; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
@Override public Object getCredentials() { return serviceId; }
@Override public Object getDetails() { return null; }
@Override public Object getPrincipal() { return serviceId; }
@Override public boolean isAuthenticated() { return authenticated; }
@Override public void setAuthenticated(boolean isAuthenticated) { this.authenticated = isAuthenticated; }
@Override public String getName() { return serviceId; }}I secured the internal endpoints with service-level authorization:
@RestController@RequestMapping("/api/internal/inventory")public class InternalInventoryController {
@GetMapping("/stock/{productId}") @PreAuthorize("hasAuthority('READ_INVENTORY')") public StockInfo getStockInfo(@PathVariable String productId) { // Only services with READ_INVENTORY authority can access return inventoryService.getStock(productId); }
@PostMapping("/stock/update") @PreAuthorize("hasAuthority('UPDATE_STOCK')") public void updateStock(@RequestBody StockUpdateRequest request) { // Only services with UPDATE_STOCK authority can access inventoryService.updateStock(request); }}The calling service generates a JWT token with its secret:
@Servicepublic class ServiceTokenGenerator {
@Value("${service.id}") private String serviceId;
@Value("${service.secret}") private String serviceSecret;
private final JwtEncoder jwtEncoder;
public String generateServiceToken() { Instant now = Instant.now(); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer(serviceId) .subject(serviceId) .issuedAt(now) .expiresAt(now.plus(1, ChronoUnit.HOURS)) .build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); }}When calling another service:
@Servicepublic class OrderService {
private final RestTemplate restTemplate; private final ServiceTokenGenerator tokenGenerator;
public void createOrder(OrderRequest request) { String serviceToken = tokenGenerator.generateServiceToken();
HttpHeaders headers = new HttpHeaders(); headers.set("X-Service-Token", serviceToken);
HttpEntity<?> entity = new HttpEntity<>(headers);
InventoryInfo stock = restTemplate.exchange( "https://inventory-service/api/internal/inventory/stock/" + request.getProductId(), HttpMethod.GET, entity, InventoryInfo.class ).getBody(); }}This approach worked well for my simple setup. I have control over the secret keys and can rotate them manually.
But as I added more services, managing secrets became painful. I tried storing them in environment variables, but that wasn’t scalable.
Approach 2: OAuth2 Client Credentials Flow
I decided to use OAuth2 with a centralized authorization server (I used Keycloak).
First, I registered each service as an OAuth2 client:
spring: security: oauth2: client: registration: inventory-service: client-id: ${INVENTORY_SERVICE_CLIENT_ID} client-secret: ${INVENTORY_SERVICE_CLIENT_SECRET} authorization-grant-type: client_credentials scope: inventory.read, inventory.write provider: auth-server: issuer-uri: https://auth.example.comI created a service token client that uses Spring’s OAuth2 support:
@Servicepublic class ServiceTokenClient {
private final OAuth2AuthorizedClientManager clientManager;
public String getServiceToken(String serviceName) { OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest .withClientRegistrationId(serviceName) .principal("service-principal") .build();
OAuth2AuthorizedClient client = clientManager.authorize(request);
return client.getAccessToken().getTokenValue(); }}I configured the resource server to validate OAuth2 tokens:
@Configuration@EnableWebSecuritypublic class ResourceServerConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter( jwtAuthenticationConverter()))) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/**").authenticated() .requestMatchers("/api/internal/**").hasAuthority("SCOPE_inventory.read") .anyRequest().permitAll());
return http.build(); }
@Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( grantedAuthoritiesConverter);
return jwtAuthenticationConverter; }}When calling another service, I used the standard Bearer token:
@Servicepublic class OrderServiceClient {
private final ServiceTokenClient tokenClient; private final RestTemplate restTemplate;
public InventoryInfo checkInventory(String productId) { String token = tokenClient.getServiceToken("order-service");
HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token);
HttpEntity<?> entity = new HttpEntity<>(headers);
return restTemplate.exchange( "https://inventory-service/api/internal/inventory/stock/" + productId, HttpMethod.GET, entity, InventoryInfo.class ).getBody(); }}This approach gave me centralized token management. The auth server handles token rotation automatically, and I can revoke tokens if needed.
But the auth server is a single point of failure and adds network latency to every service call.
Approach 3: Service Mesh with mTLS
I deployed my services to Kubernetes with Istio service mesh. This offloads authentication to the infrastructure layer.
First, I created an Istio authorization policy:
apiVersion: security.istio.io/v1beta1kind: AuthorizationPolicymetadata: name: inventory-service-policy namespace: productionspec: selector: matchLabels: app: inventory-service rules: - from: - source: principals: ["cluster.local/ns/production/sa/order-service"] to: - operation: methods: ["GET"] paths: ["/api/internal/*"] - from: - source: principals: ["cluster.local/ns/production/sa/order-service"] to: - operation: methods: ["POST"] paths: ["/api/internal/stock/update"]The Spring Security config became much simpler:
@Configurationpublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/**").authenticated() .requestMatchers("/api/internal/**").authenticated() .anyRequest().permitAll());
return http.build(); }}Istio handles mTLS automatically. Each service gets a certificate from the mesh CA, and all inter-service communication is encrypted and authenticated.
This approach required the least application code, but it only works on Kubernetes with a service mesh. It’s also harder to test locally.
Securing Internal Endpoints
Regardless of the authentication approach, I followed these best practices:
Separate Controllers
I created separate controllers for public and internal APIs:
@RestController@RequestMapping("/api/v1/products")public class ProductController { // User-facing endpoints with user authentication}
@RestController@RequestMapping("/api/internal/inventory")public class InternalInventoryController { // Service-to-service endpoints only}This makes it clear which endpoints are for services only.
Use @PreAuthorize for Role-Based Access
I used Spring Security annotations to control access:
@GetMapping("/stock/{productId}")@PreAuthorize("hasAuthority('READ_INVENTORY')")public StockInfo getStockInfo(@PathVariable String productId) { return inventoryService.getStock(productId);}
@PostMapping("/stock/update")@PreAuthorize("hasAuthority('UPDATE_STOCK')")public void updateStock(@RequestBody StockUpdateRequest request) { inventoryService.updateStock(request);}
@GetMapping("/orders/{id}")@PreAuthorize("hasAnyAuthority('SERVICE_ORDER', 'SERVICE_PAYMENT')")public Order getOrder(@PathVariable String id) { // Multiple services can access}Combine User and Service Authentication
Some endpoints need to support both:
@RestControllerpublic class HybridController {
@GetMapping("/api/data") @PreAuthorize("isAuthenticated()") public Data getData(Authentication auth) { if (auth instanceof ServiceAuthentication) { return handleServiceRequest(auth); } else if (auth instanceof JwtAuthenticationToken) { return handleUserRequest(auth); } throw new AccessDeniedException("Unknown principal type"); }}Comparison
| Approach | Complexity | Security | Scalability | When to use |
|---|---|---|---|---|
| JWT Service Auth | Low | Medium | Medium | < 10 services, simple setup |
| OAuth2 Client Credentials | Medium | High | High | Enterprise, centralized auth |
| Service Mesh mTLS | High | Very High | Very High | Kubernetes, Istio/Linkerd |
I chose OAuth2 Client Credentials for my production deployment. It gives me enterprise-grade security without requiring Kubernetes. I can scale to dozens of services and manage tokens centrally.
The reason
The key reason I needed separate service authentication is that user tokens don’t prove service identity. A user token tells you WHO is making the request, but not WHICH service is making the request.
Service-to-service authentication requires proving service identity. Each service must authenticate as itself, not as a user. This prevents:
- Unauthorized services accessing internal APIs
- Users bypassing service boundaries
- Privilege escalation through token forwarding
The authentication approach I chose depends on my infrastructure:
- Simple setup → JWT with shared secrets
- Centralized auth → OAuth2 client credentials
- Kubernetes → Service mesh mTLS
Summary
In this post, I showed how to implement service-to-service authentication in Spring Boot microservices. I covered three approaches:
- JWT-based authentication with shared secrets for simple architectures
- OAuth2 client credentials flow for centralized authentication management
- Service mesh mTLS for zero-trust security at the infrastructure level
The key point is that service authentication is different from user authentication. Services need their own identity and credentials, and internal endpoints must be secured with service-level authorization.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Security OAuth2 Resource Server
- 👨💻 Spring Boot Microservices Security
- 👨💻 Istio Service Mesh Security
- 👨💻 OAuth 2.0 Client Credentials Flow
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments