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:
External Clients (Web, Mobile) ↓ Spring Cloud Gateway ↓ ┌──────────────┐ │ Microservice │ │ │ │ /api/public │ ← Should be accessible via gateway │ /internal │ ← Should NOT be accessible via gateway └──────────────┘ ↓ Other Internal ServicesThe 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:
spring: cloud: gateway: routes: - id: user-service-public uri: lb://user-service predicates: - Path=/api/v1/public/** filters: - StripPrefix=0
# Do NOT route internal pathsThis 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:
@Configuration@EnableWebSecuritypublic 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:
@Componentpublic 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:
- Gateway doesn’t route
/internal/**paths - 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:
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:
@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:
@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:
apiVersion: v1kind: Servicemetadata: name: rest-api-servicespec: selector: app: rest-api-module ports: - port: 80 targetPort: 8080---apiVersion: v1kind: Servicemetadata: name: frontend-module-servicespec: selector: app: frontend-module ports: - port: 8081 targetPort: 8081 # No ingress - internal cluster access onlyThis 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.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: internal-endpoints-policy namespace: defaultspec: 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: 8081This 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:
| Approach | Complexity | Security | Maintainability | Best For |
|---|---|---|---|---|
| Path-Based Routing | Low | Medium-High | High | Simple microservices |
| Separate Modules | Medium | High | Medium | Large applications |
| Network Policies | Low | High | High | Any 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:
@SpringBootTest@AutoConfigureMockMvcclass 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
-
Relying only on gateway routing: I initially thought gateway configuration was enough. But if someone has direct network access, they bypass the gateway entirely.
-
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/**. -
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.
-
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:
- 👨💻 Spring Cloud Gateway Documentation
- 👨💻 Spring Security Reference Guide
- 👨💻 Kubernetes NetworkPolicies Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments