Skip to content

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:

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:

UserController.java
@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.

SecurityRoles.java
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:

SecurityConfig.java
@Configuration
@EnableWebSecurity
public 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:

InternalController.java
@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:

Internal JWT Token Structure
{
"sub": "service-a",
"roles": ["INTERNAL_SERVICE"],
"scope": "internal",
"service_name": "service-a",
"iat": 1709875200,
"exp": 1709878800
}

Then the calling service would use this token:

InternalServiceClient.java
@Service
public 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:

GatewayConfig.java
@Configuration
public 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:

InternalRequestFilter.java
@Component
public 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:

GatewaySignatureFilter.java
@Component
public 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:

GatewayHeaderFilter.java
@Component
public 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:

internal-service-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: internal-service-policy
namespace: production
spec:
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: 8080

This 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:

IpSecurityConfig.java
@Configuration
public 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();
}
}

After testing each solution individually, I realized the best approach combines multiple security layers:

ComprehensiveSecurityConfig.java
@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:

Multi-Layer Security 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:

  1. Role-based access control with INTERNAL_SERVICE role
  2. API Gateway with signed headers
  3. Kubernetes Network Policies or IP whitelisting
  4. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments