Skip to content

How to protect internal endpoints from external access in Spring Boot API gateway

Purpose

This post demonstrates how to prevent external access to internal service endpoints when using Spring Cloud Gateway in a microservices architecture.

Environment

  • Spring Boot 3.2
  • Spring Cloud Gateway 4.1
  • Kubernetes 1.29
  • Java 21

The Challenge

When I set up a Spring Boot microservices architecture with Spring Cloud Gateway, I ran into a security problem. We had two types of endpoints:

Architecture Overview
External Clients (Web, Mobile)
Spring Cloud Gateway
┌──────────────┐
│ Microservice │
│ │
│ /api/public │ ← Should be accessible via gateway
│ /internal │ ← Should NOT be accessible via gateway
└──────────────┘
Other Internal Services

The problem was that my API gateway configuration was routing ALL endpoints to the microservice, including internal ones meant only for service-to-service communication. This meant external clients could potentially access sensitive operations like:

  • Administrative endpoints
  • Internal batch processing jobs
  • Service health checks with detailed information
  • APIs that return internal user data (permissions, audit logs)

I needed to block /internal/** paths from being accessible through the API gateway while still allowing other microservices to call them.

First Attempt: Path-Based Routing

I tried to solve this by using path prefixes in Spring Cloud Gateway configuration:

application.yml
spring:
cloud:
gateway:
routes:
- id: user-service-public
uri: lb://user-service
predicates:
- Path=/api/v1/public/**
filters:
- StripPrefix=0
# Do NOT route internal paths

This approach worked for the gateway level - requests to /internal/** simply returned 404. But I realized this wasn’t enough. If someone had direct network access to the microservice (bypassing the gateway), they could still access internal endpoints.

So I added a second layer of protection with Spring Security:

SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").authenticated()
.requestMatchers("/internal/**").hasAuthority("SERVICE_ROLE")
.anyRequest().denyAll()
)
.addFilterBefore(new InternalEndpointFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

The custom filter checks the client IP address:

InternalEndpointFilter.java
@Component
public class InternalEndpointFilter extends OncePerRequestFilter {
private static final List<String> ALLOWED_IPS = Arrays.asList(
"10.0.0.0/8", // Kubernetes pod network
"172.16.0.0/12", // Docker network
"192.168.0.0/16" // Private network
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String requestPath = request.getRequestURI();
if (requestPath.startsWith("/internal/")) {
String clientIp = request.getRemoteAddr();
if (!isInternalIp(clientIp)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Internal endpoints are not accessible from external networks");
return;
}
}
filterChain.doFilter(request, response);
}
private boolean isInternalIp(String ip) {
return ALLOWED_IPS.stream()
.anyMatch(range -> isInRange(ip, range));
}
}

This approach worked well for simple cases. I had two layers of protection:

  1. Gateway doesn’t route /internal/** paths
  2. Even if bypassed, Spring Security blocks external IPs

Second Attempt: Separate Modules

For larger applications, I tried the “separate modules” approach suggested in many architectural patterns. I split the application into:

Module Structure
my-application/
├── rest-api-module/ # Exposed via API gateway
│ └── src/main/java/
│ └── com/example/restapi/
│ ├── controller/
│ │ └── UserController.java
│ ├── dto/
│ │ └── UserPublicDto.java
│ └── service/
├── frontend-module/ # Internal service
│ └── src/main/java/
│ └── com/example/frontend/
│ ├── controller/
│ │ └── FrontendApiController.java
│ ├── dto/
│ │ └── UserInternalDto.java
│ └── service/
└── shared-library/ # Common code
└── src/main/java/
└── com/example/shared/
├── domain/
└── repository/

The REST API module only exposes public DTOs:

UserController.java
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserPublicDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getPublicUser(id));
}
}

The frontend module exposes internal DTOs with sensitive data:

FrontendApiController.java
@RestController
@RequestMapping("/internal/v1/users")
public class FrontendApiController {
private final UserService userService;
@GetMapping("/{id}/full-details")
public ResponseEntity<UserInternalDto> getUserFullDetails(
@PathVariable Long id) {
return ResponseEntity.ok(userService.getInternalUserDetails(id));
}
@GetMapping("/{id}/permissions")
public ResponseEntity<UserPermissions> getUserPermissions(
@PathVariable Long id) {
// Never exposed externally
return ResponseEntity.ok(userService.getUserPermissions(id));
}
}

In Kubernetes, I deployed them as separate services:

rest-api-deployment.yaml
apiVersion: v1
kind: Service
metadata:
name: rest-api-service
spec:
selector:
app: rest-api-module
ports:
- port: 80
targetPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: frontend-module-service
spec:
selector:
app: frontend-module
ports:
- port: 8081
targetPort: 8081
# No ingress - internal cluster access only

This approach provided stronger separation since the internal service doesn’t even have an external ingress route. The tradeoff was increased deployment complexity and potential code duplication if not carefully managed with a shared library.

Third Attempt: Kubernetes Network Policies

I then added Kubernetes NetworkPolicies for network-level isolation. This provides defense in depth - even if the application security is misconfigured, network policies prevent external access.

network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: internal-endpoints-policy
namespace: default
spec:
podSelector:
matchLabels:
app: my-microservice
policyTypes:
- Ingress
ingress:
# Allow traffic only from API gateway for public endpoints
- from:
- podSelector:
matchLabels:
app: api-gateway
ports:
- protocol: TCP
port: 8080
# Allow all internal traffic for internal endpoints
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 8081

This policy ensures that:

  • Port 8080 (public API) only accepts traffic from the API gateway pod
  • Port 8081 (internal API) accepts traffic from any pod in the namespace
  • External traffic to either port is blocked at the network level

Which Approach to Use?

I tested each approach and here’s what I found:

ApproachComplexitySecurityMaintainabilityBest For
Path-Based RoutingLowMedium-HighHighSimple microservices
Separate ModulesMediumHighMediumLarge applications
Network PoliciesLowHighHighAny Kubernetes deployment

For most cases, I recommend combining path-based routing with network policies. The separate modules approach makes sense when you have distinct external and internal domains with different data models.

Testing the Solution

I wrote integration tests to verify internal endpoints are protected:

InternalEndpointSecurityTest.java
@SpringBootTest
@AutoConfigureMockMvc
class InternalEndpointSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void internalEndpointShouldNotBeAccessibleFromGateway()
throws Exception {
mockMvc.perform(get("/internal/v1/users/1/details")
.header("X-Forwarded-For", "external-client-ip"))
.andExpect(status().isForbidden());
}
@Test
void internalEndpointAccessibleFromInternalNetwork()
throws Exception {
mockMvc.perform(get("/internal/v1/users/1/details")
.header("X-Forwarded-For", "10.0.1.5"))
.andExpect(status().isOk());
}
}

Common Mistakes I Made

  1. Relying only on gateway routing: I initially thought gateway configuration was enough. But if someone has direct network access, they bypass the gateway entirely.

  2. Inconsistent naming: I started with /internal/** on some services and /admin/** on others. This made it hard to write consistent security policies. I standardized on /internal/**.

  3. Forgetting to update routes: When I added new internal endpoints, I sometimes forgot to verify they weren’t exposed through the gateway. Automated tests caught this.

  4. Copy-paste errors: I accidentally copied @RequestMapping("/api/v1/public/**") to an internal controller. Code reviews helped catch this.

Summary

In this post, I showed how to protect internal endpoints from external access in a Spring Boot microservices architecture with Spring Cloud Gateway. The key point is to use multiple layers of defense: path-based routing in the gateway, Spring Security filters, and Kubernetes NetworkPolicies.

For most applications, combining path-based routing with network policies provides good security without excessive complexity. The separate modules approach is worth considering for large applications with distinct external and internal concerns.

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