Spring BootMicroservice Security
Spring Boot

Microservice Security

Securing a microservices architecture requires protecting traffic at multiple layers: authenticating external clients at the API Gateway, propagating identity between internal services via JWT, securing service-to-service communication with mutual TLS or OAuth2 client credentials, and applying method-level authorization within each service. The principle of defence in depth means no single layer is trusted exclusively.

Security Architecture Overview

A secure microservices system has three distinct security boundaries. The external boundary (API Gateway) authenticates all inbound client requests and rejects unauthenticated ones before they reach any service. The internal boundary governs service-to-service communication — services must not blindly trust calls from other services. The service boundary applies fine-grained authorisation within each service using method-level security.
Java
// ── Three-layer security model: ───────────────────────────────────────
//
//  ┌────────────────────────────────────────────────────────────────┐
//  │  LAYER 1: External boundary (API Gateway)                      │
//  │  • Validate JWT / API key on every inbound request             │
//  │  • Reject unauthenticated/unauthorised requests → 401/403
//  │  • Strip raw token; forward user identity in trusted headers   │
//  │  • Rate limiting, IP allow-lists, DDoS protection              │
//  └───────────────────────┬────────────────────────────────────────┘
//                          │ X-User-Id: 42
//                          │ X-User-Role: ADMIN
//                          ▼
//  ┌────────────────────────────────────────────────────────────────┐
//  │  LAYER 2: Service-to-service (internal network)                │
//  │  • Services authenticate each other (OAuth2 client creds /     │
//  │    mutual TLS) — not just "came from inside the network"
//  │  • Internal JWT forwarded in Authorization header              │
//  │  • Each service verifies the token before processing           │
//  └───────────────────────┬────────────────────────────────────────┘
//                          │
//                          ▼
//  ┌────────────────────────────────────────────────────────────────┐
//  │  LAYER 3: Service-level authorisation                          │
//  │  • @PreAuthorize on each endpoint / method                     │
//  │  • Role and permission checks per operation                    │
//  │  • Ownership checks (user can only access their own data)      │
//  └────────────────────────────────────────────────────────────────┘

// ── Zero-trust principle: ─────────────────────────────────────────────
// Never assume a request is safe because it arrived on the internal network.
// Every service must authenticate and authorise every caller.
// A compromised internal service should not be able to access all others.

JWT Authentication at the API Gateway

The API Gateway is responsible for validating the JWT on every inbound request. A valid token is stripped and replaced with trusted identity headers forwarded to downstream services. Downstream services trust these headers because they come from the gateway, not from external clients.
Java
// ── Gateway JWT validation filter: ──────────────────────────────────
@Component
@Order(1)
@RequiredArgsConstructor
@Slf4j
public class JwtGatewayFilter implements GlobalFilter {

    private final JwtTokenProvider jwtProvider;

    private static final Set<String> PUBLIC_PATHS = Set.of(
        "/api/auth/login",
        "/api/auth/register",
        "/api/auth/refresh",
        "/actuator/health"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
                             GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        if (PUBLIC_PATHS.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return unauthorised(exchange, "Missing or invalid Authorization header");
        }

        String token = authHeader.substring(7);

        try {
            jwtProvider.validateToken(token);
        } catch (ExpiredJwtException ex) {
            return unauthorised(exchange, "Token has expired");
        } catch (JwtException ex) {
            return unauthorised(exchange, "Invalid token");
        }

        // Extract claims and forward as trusted headers to downstream:
        String userId   = jwtProvider.getClaim(token, "sub");
        String role     = jwtProvider.getClaim(token, "role");
        String tenantId = jwtProvider.getClaim(token, "tenantId");

        ServerHttpRequest mutated = exchange.getRequest().mutate()
            .header("X-User-Id",   userId)
            .header("X-User-Role", role)
            .header("X-Tenant-Id", tenantId)
            .header(HttpHeaders.AUTHORIZATION, "")   // strip raw token
            .build();

        return chain.filter(exchange.mutate().request(mutated).build());
    }

    private Mono<Void> unauthorised(ServerWebExchange exchange,
                                    String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders()
            .add(HttpHeaders.CONTENT_TYPE,
                 MediaType.APPLICATION_JSON_VALUE);
        DataBuffer buffer = response.bufferFactory().wrap(
            ("{"error":"" + message + ""}").getBytes());
        return response.writeWith(Mono.just(buffer));
    }
}

// ── JwtTokenProvider — shared utility: ───────────────────────────────
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secret;

    public void validateToken(String token) {
        Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
            .build()
            .parseClaimsJws(token);
    }

    public String getClaim(String token, String claim) {
        return Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
            .build()
            .parseClaimsJws(token)
            .getBody()
            .get(claim, String.class);
    }
}

Security in Downstream Services

Each downstream service reads the trusted identity headers forwarded by the gateway and reconstructs a Spring Security context. This lets @PreAuthorize, @PostAuthorize, and authentication.getName() work normally throughout the service without re-validating a JWT on every call.
Java
// ── Security config in each downstream service: ──────────────────────
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ServiceSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http)
            throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .addFilterBefore(
                new TrustedHeaderAuthFilter(),
                UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated());
        return http.build();
    }
}

// ── Filter that reads gateway-forwarded headers: ──────────────────────
public class TrustedHeaderAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String userId = request.getHeader("X-User-Id");
        String role   = request.getHeader("X-User-Role");

        if (StringUtils.hasText(userId) && StringUtils.hasText(role)) {
            List<GrantedAuthority> authorities = List.of(
                new SimpleGrantedAuthority("ROLE_" + role)
            );

            UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(
                    userId, null, authorities);

            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        chain.doFilter(request, response);
    }
}

// ── Method-level security now works normally: ─────────────────────────
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    @GetMapping
    @PreAuthorize("hasRole('ADMIN')")
    public List<OrderResponse> findAll() {
        return orderService.findAll();
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or " +
                  "#id == authentication.principal")
    public OrderResponse findById(@PathVariable Long id) {
        return orderService.findById(id);
    }

    @PostMapping
    @PreAuthorize("isAuthenticated()")
    public OrderResponse create(
            @RequestBody CreateOrderRequest request,
            @AuthenticationPrincipal String userId) {
        return orderService.create(request, Long.parseLong(userId));
    }
}

Service-to-Service Authentication (OAuth2 Client Credentials)

When one microservice calls another directly (bypassing the gateway), it must authenticate itself. The OAuth2 Client Credentials flow is the standard approach — the calling service obtains a short-lived access token from an Authorization Server using its own client ID and secret, then includes it in the Authorization header of every outbound call.
yaml
<!-- pom.xml — OAuth2 client for service-to-service calls: -->
<!-- <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency> -->

// ── application.yml — register the service as an OAuth2 client: ───────
// spring:
//   security:
//     oauth2:
//       client:
//         registration:
//           order-service:
//             client-id: order-service
//             client-secret: ${ORDER_SERVICE_SECRET}
//             authorization-grant-type: client_credentials
//             scope: internal.read, internal.write
//         provider:
//           order-service:
//             token-uri: http://auth-server:9000/oauth2/token

// ── Configure WebClient to automatically attach access tokens: ────────
@Configuration
@RequiredArgsConstructor
public class OAuth2WebClientConfig {

    private final OAuth2AuthorizedClientManager authorizedClientManager;

    @Bean
    public WebClient internalWebClient() {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(
                authorizedClientManager);
        oauth2.setDefaultClientRegistrationId("order-service");

        return WebClient.builder()
            .apply(oauth2.oauth2Configuration())
            .build();
    }

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clients,
            OAuth2AuthorizedClientRepository authorizedClients) {

        OAuth2AuthorizedClientProvider provider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .build();

        DefaultOAuth2AuthorizedClientManager manager =
            new DefaultOAuth2AuthorizedClientManager(
                clients, authorizedClients);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }
}

// ── Use — token is fetched and refreshed automatically: ───────────────
@Service
@RequiredArgsConstructor
public class InternalOrderService {

    private final WebClient internalWebClient;

    public UserResponse getUser(Long userId) {
        return internalWebClient.get()
            .uri("http://user-service/internal/users/" + userId)
            // Authorization: Bearer <access-token> added automatically
            .retrieve()
            .bodyToMono(UserResponse.class)
            .block();
    }
}

Securing Internal Endpoints

Services should distinguish between external-facing endpoints (validated by the gateway) and internal endpoints (called only by other services). Internal endpoints require a valid service token or a trusted-network header and should never be accessible from outside the cluster. In Kubernetes, NetworkPolicy restricts which pods can call which services.
Java
// ── Separate security rules for external vs internal endpoints: ───────
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class MixedSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http)
            throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s
                .sessionCreationPolicy(STATELESS))
            .addFilterBefore(
                new TrustedHeaderAuthFilter(),
                UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth
                // Health and metrics — open:
                .requestMatchers("/actuator/health",
                                 "/actuator/info").permitAll()

                // Internal endpoints — require INTERNAL role:
                // (set by TrustedHeaderAuthFilter when X-User-Role=INTERNAL)
                .requestMatchers("/internal/**")
                    .hasRole("INTERNAL")

                // Public API — require authenticated user:
                .requestMatchers("/api/**")
                    .authenticated()

                .anyRequest().denyAll()
            );
        return http.build();
    }
}

// ── Internal controller — only callable by other services: ────────────
@RestController
@RequestMapping("/internal/users")
public class InternalUserController {

    private final UserRepository userRepository;

    // This endpoint is NOT exposed through the API Gateway.
    // Only other internal services (with ROLE_INTERNAL) can call it.
    @GetMapping("/{id}")
    @PreAuthorize("hasRole('INTERNAL')")
    public UserInternalResponse findById(@PathVariable Long id) {
        return UserInternalResponse.from(
            userRepository.findById(id).orElseThrow());
    }

    @GetMapping("/batch")
    @PreAuthorize("hasRole('INTERNAL')")
    public List<UserInternalResponse> findByIds(
            @RequestParam List<Long> ids) {
        return userRepository.findAllById(ids).stream()
            .map(UserInternalResponse::from)
            .toList();
    }
}

// ── Kubernetes NetworkPolicy — only allow pod-to-pod traffic: ─────────
// apiVersion: networking.k8s.io/v1
// kind: NetworkPolicy
// metadata:
//   name: user-service-internal-policy
// spec:
//   podSelector:
//     matchLabels:
//       app: user-service
//   ingress:
//     - from:
//         - podSelector:
//             matchLabels:
//               role: internal-service   # only pods with this label
//       ports:
//         - protocol: TCP
//           port: 8081
//   # External traffic comes only through the gateway pod.

Secrets Management

Microservices require many secrets — database passwords, JWT signing keys, OAuth2 client secrets, API keys. These must never be hard-coded in source code or committed to version control. The recommended approaches are environment variables injected at runtime, Kubernetes Secrets mounted as volumes, or a dedicated secrets manager such as HashiCorp Vault or AWS Secrets Manager.
yaml
// ── Anti-pattern — NEVER do this: ────────────────────────────────────
// application.yml:
// jwt:
//   secret: myHardCodedSecretKey123   ← committed to git → compromised
// spring:
//   datasource:
//     password: admin123              ← visible in source code

// ── Option 1: Environment variables (simplest): ───────────────────────
// application.yml:
// jwt:
//   secret: ${JWT_SECRET}            ← injected at runtime from env
// spring:
//   datasource:
//     password: ${DB_PASSWORD}

// Docker / Kubernetes: set environment variables in the deployment:
// env:
//   - name: JWT_SECRET
//     valueFrom:
//       secretKeyRef:
//         name: app-secrets
//         key: jwt-secret

// ── Option 2: Kubernetes Secrets: ────────────────────────────────────
// kubectl create secret generic app-secrets //   --from-literal=jwt-secret=<value> //   --from-literal=db-password=<value>
//
// Mount as environment variables (as above) or as a volume:
// volumes:
//   - name: secrets-vol
//     secret:
//       secretName: app-secrets
// volumeMounts:
//   - name: secrets-vol
//     mountPath: /etc/secrets
//     readOnly: true

// ── Option 3: HashiCorp Vault with Spring Cloud Vault: ────────────────
// pom.xml:
// <dependency>
//   <groupId>org.springframework.cloud</groupId>
//   <artifactId>spring-cloud-starter-vault-config</artifactId>
// </dependency>

// bootstrap.yml:
// spring:
//   cloud:
//     vault:
//       host: vault.internal
//       port: 8200
//       scheme: https
//       authentication: KUBERNETES   # authenticate using K8s service account
//       kubernetes:
//         role: order-service
//       kv:
//         enabled: true
//         backend: secret
//         default-context: order-service   # reads secret/order-service/*

// Vault stores:
// secret/order-service/jwt.secret    = <value>
// secret/order-service/db.password   = <value>
//
// Spring Cloud Vault fetches these on startup and injects them as
// Spring properties — no code change needed, secrets never touch disk.