How to Secure Internal Endpoints from External Access in Spring Boot Microservices
Problem
When I deployed a microservices architecture with Spring Boot, I realized my internal endpoints were accessible to anyone who knew the URLs. Even admin users could bypass the API Gateway and call service-to-service endpoints directly.
Here’s my architecture:
┌─────────────┐ ┌─────────────┐│ External │ │ Admin ││ User │ │ User │└─────────────┘ └─────────────┘ │ │ │ ✓ Valid Path │ ✗ Bypass ▼ ▼┌─────────────────────────────────┐│ API Gateway ││ Public Entry Point │└─────────────────────────────────┘ │ │ ├──────────────────┤ │ │ ▼ ▼┌─────────────┐ ┌─────────────┐│ Service A │───→│ Service B ││ Public + │ │ Internal ││ Internal │ │ Endpoints │└─────────────┘ └─────────────┘I needed to ensure internal endpoints could only be accessed by other services, not by external users or even admin accounts.
Environment
- Spring Boot 3.2
- Spring Security 6.2
- Spring Cloud Gateway 4.1
- Java 21
- Kubernetes 1.29
What happened?
I started with a simple controller that had both public and internal endpoints:
@RestController@RequestMapping("/api/users")public class UserController {
@GetMapping("/{id}") public UserDTO getUser(@PathVariable Long id) { return userService.getUserById(id); }
@GetMapping("/{id}/internal-details") public InternalUserDetails getInternalDetails(@PathVariable Long id) { return userService.getInternalUserDetails(id); }
@PostMapping("/cache/invalidate") public void invalidateCache() { cacheService.invalidateAll(); }}The problem was clear: anyone with a valid authentication token could call /api/users/{id}/internal-details or /api/users/cache/invalidate. These operations contained sensitive data and administrative functions that should only be accessible by other services.
Solution 1: Role-Based Access Control
I first tried using Spring Security roles to differentiate between internal service calls and external user requests.
public class SecurityRoles { public static final String INTERNAL_SERVICE = "INTERNAL_SERVICE"; public static final String ADMIN = "ADMIN"; public static final String USER = "USER";}Then I configured the security filter chain:
@Configuration@EnableWebSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/api/internal/**").hasRole("INTERNAL_SERVICE") .requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "INTERNAL_SERVICE") .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt());
return http.build(); }}I created a separate controller for internal endpoints:
@RestController@RequestMapping("/api/internal")public class InternalController {
@GetMapping("/users/{id}/details") @PreAuthorize("hasRole('INTERNAL_SERVICE')") public InternalUserDetails getUserDetails(@PathVariable Long id) { return userService.getInternalUserDetails(id); }
@PostMapping("/cache/invalidate") @PreAuthorize("hasRole('INTERNAL_SERVICE')") public void invalidateCache() { cacheService.invalidateAll(); }}For service-to-service communication, I needed to issue JWT tokens with the INTERNAL_SERVICE role:
{ "sub": "service-a", "roles": ["INTERNAL_SERVICE"], "scope": "internal", "service_name": "service-a", "iat": 1709875200, "exp": 1709878800}Then the calling service would use this token:
@Servicepublic class InternalServiceClient {
@Value("${internal.service.token}") private String internalServiceToken;
private final WebClient webClient;
public InternalServiceClient(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.build(); }
public InternalUserDetails callInternalEndpoint(Long userId) { return webClient.get() .uri("http://service-a/api/internal/users/{id}/details", userId) .headers(headers -> headers.setBearerAuth(internalServiceToken)) .retrieve() .bodyToMono(InternalUserDetails.class) .block(); }}This approach worked for application-level security, but I realized it had limitations. Anyone who could obtain an internal service token would have full access. Also, this didn’t prevent direct network access to the service.
Solution 2: API Gateway with Request Headers
I then tried adding a layer of security at the API Gateway level. The gateway would add a header that services would validate.
First, I configured the gateway to add internal headers:
@Configurationpublic class GatewayConfig {
@Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("service-a", r -> r .path("/api/service-a/**") .filters(f -> f .stripPrefix(2) .addRequestHeader("X-Internal-Request", "true") .addRequestHeader("X-Gateway-Timestamp", String.valueOf(System.currentTimeMillis())) ) .uri("lb://service-a") ) .build(); }}Then I created a filter in the service to validate this header:
@Componentpublic class InternalRequestFilter extends OncePerRequestFilter {
private static final String INTERNAL_HEADER = "X-Internal-Request"; private static final String INTERNAL_HEADER_VALUE = "true";
@Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (requestURI.startsWith("/api/internal/")) { String internalHeader = request.getHeader(INTERNAL_HEADER);
if (!INTERNAL_HEADER_VALUE.equals(internalHeader)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Direct access to internal endpoints is not allowed"); return; } }
filterChain.doFilter(request, response); }}But I realized simple headers could be spoofed. Anyone who knew the header name could add it manually. So I enhanced the gateway to add a signed header:
@Componentpublic class GatewaySignatureFilter implements GlobalFilter {
@Value("${gateway.secret-key}") private String secretKey;
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String timestamp = String.valueOf(System.currentTimeMillis()); String signature = generateSignature(timestamp, secretKey);
ServerHttpRequest request = exchange.getRequest().mutate() .header("X-Gateway-Timestamp", timestamp) .header("X-Gateway-Signature", signature) .build();
return chain.filter(exchange.mutate().request(request).build()); }
private String generateSignature(String timestamp, String key) { try { Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(), "HmacSHA256"); sha256_HMAC.init(secret_key); return Base64.getEncoder().encodeToString( sha256_HMAC.doFinal(timestamp.getBytes()) ); } catch (Exception e) { throw new RuntimeException("Failed to generate signature", e); } }}The service then validates the signature:
@Componentpublic class GatewayHeaderFilter extends OncePerRequestFilter {
private final String gatewaySecret;
public GatewayHeaderFilter(@Value("${gateway.secret-key}") String gatewaySecret) { this.gatewaySecret = gatewaySecret; }
@Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (requestURI.startsWith("/api/internal/")) { String timestamp = request.getHeader("X-Gateway-Timestamp"); String signature = request.getHeader("X-Gateway-Signature");
if (timestamp == null || signature == null || !validateSignature(timestamp, signature)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid gateway signature"); return; } }
filterChain.doFilter(request, response); }
private boolean validateSignature(String timestamp, String signature) { try { String expectedSignature = generateSignature(timestamp, gatewaySecret);
long requestTime = Long.parseLong(timestamp); long currentTime = System.currentTimeMillis(); long maxAge = 300000; // 5 minutes
if (currentTime - requestTime > maxAge) { return false; }
return expectedSignature.equals(signature); } catch (Exception e) { return false; } }}This was better, but I realized this approach still had a fundamental problem: the gateway was a single point of failure, and it didn’t prevent network-level direct access to the services.
Solution 3: Network-Level Security
I decided to add infrastructure-level security using Kubernetes Network Policies:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: internal-service-policy namespace: productionspec: podSelector: matchLabels: app: service-b tier: internal policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: service-a - podSelector: matchLabels: app: api-gateway ports: - protocol: TCP port: 8080This policy ensures only specific services can communicate with internal services. Even if someone bypasses the application-level security, the network policy blocks the request.
For environments not using Kubernetes, I added IP-based access control in Spring Security:
@Configurationpublic class IpSecurityConfig {
@Value("${internal.allowed-ips}") private List<String> allowedInternalIps;
@Bean public SecurityFilterChain internalSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/internal/**") .authorizeHttpRequests(auth -> auth .anyRequest().access(new IpAddressMatcher(allowedInternalIps)) );
return http.build(); }}Combined Approach (Recommended)
After testing each solution individually, I realized the best approach combines multiple security layers:
@Configuration@EnableWebSecurity@EnableGlobalMethodSecurity(prePostEnabled = true)public class ComprehensiveSecurityConfig {
@Bean @Order(1) public SecurityFilterChain internalApiSecurity(HttpSecurity http) throws Exception { http .securityMatcher("/api/internal/**") .authorizeHttpRequests(auth -> auth .anyRequest().hasRole("INTERNAL_SERVICE") ) .addFilterBefore(new GatewayHeaderFilter(), UsernamePasswordAuthenticationFilter.class) .oauth2ResourceServer(oauth2 -> oauth2.jwt());
return http.build(); }
@Bean @Order(2) public SecurityFilterChain publicApiSecurity(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt());
return http.build(); }}Here’s the complete architecture:
┌─────────────────────────────────────────────────────────────┐│ Layer 1: Network Security ││ - Kubernetes Network Policies ││ - Firewall Rules ││ - API Gateway │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ Layer 2: Application Security ││ - JWT Token Validation ││ - Role-Based Access Control ││ - Gateway Header Validation with Signature │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ Layer 3: Code-Level Security ││ - @PreAuthorize Annotations ││ - Separate Controllers for Internal/External Endpoints │└─────────────────────────────────────────────────────────────┘The reason
The key reason for using multiple security layers is that no single mechanism is sufficient. Each layer addresses different attack vectors:
- Network policies prevent direct access to the service
- JWT validation ensures the caller has valid authentication
- Role-based access ensures the caller has the right permissions
- Gateway signatures ensure requests came through the trusted gateway
- Method-level annotations provide fine-grained control
This is a defense-in-depth approach: if one layer is compromised, other layers still protect the system.
Summary
In this post, I showed how to secure internal endpoints in Spring Boot microservices from external access. The key point is to use multiple security layers rather than relying on a single mechanism.
For immediate protection, start with role-based access control and gateway header validation. Then add network-level security as your infrastructure matures. Always assume the network can be compromised and implement security at every layer.
The recommended approach combines:
- Role-based access control with
INTERNAL_SERVICErole - API Gateway with signed headers
- Kubernetes Network Policies or IP whitelisting
- Separate controller hierarchies for clear boundaries
This makes it impossible for external users to access internal endpoints, even if they know the URL, have valid credentials for public APIs, or are on the corporate network.
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 Reference
- 👨💻 Spring Cloud Gateway Documentation
- 👨💻 Kubernetes Network Policies
- 👨💻 Istio Authorization Policies
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments