- Published on
Spring Cloud Gateway with Spring Boot 4: Routing, Security, Resilience, and BFF Composition
- Authors

- Name
- Maria
A Spring Cloud Gateway deployment is more than a collection of route definitions. At the edge of a microservice system, it becomes the place where external contracts meet internal topology, authentication meets service authorization, and client latency meets downstream failure.
That position makes the gateway valuable, but it also makes poor design decisions unusually expensive. Blocking work on the Netty event loop, retries on unsafe operations, unbounded response aggregation, or a mismatched Spring Boot and Spring Cloud release train can turn the edge into a system-wide bottleneck.
This guide builds a production-oriented gateway using Spring Boot 4, Spring Cloud Gateway Server WebFlux, Spring Security, Resilience4j, and reactive WebClient composition. It also explains where Java virtual threads are useful—and why they are not the default concurrency model for the reactive gateway itself.
TL;DR Keep proxying, authentication, routing, and lightweight policy enforcement in the gateway. Apply strict timeouts, retry only safe operations, and use circuit breakers for controlled degradation. Put complex client-specific orchestration in a clear BFF boundary rather than hiding it inside low-level gateway filters.
What an API Gateway Should Own
A gateway is a north-south traffic component. It handles requests entering the system from browsers, mobile applications, partner APIs, and other external consumers.
Good gateway responsibilities include:
- route selection and path normalization;
- TLS termination at the platform or gateway boundary;
- authentication and coarse-grained authorization;
- request size limits and rate limits;
- correlation and trace context propagation;
- edge-level timeouts, retries, and circuit breakers;
- removal of untrusted or sensitive headers;
- consistent error response envelopes;
- simple protocol or contract adaptation.
A gateway should not become a second domain monolith.
Avoid placing these responsibilities at the edge:
- database transactions for core business entities;
- long-running workflows;
- ownership of domain invariants;
- large response joins across many services;
- CPU-heavy report generation;
- arbitrary third-party calls without explicit timeouts and isolation;
- client-specific logic shared by only one frontend when a separate BFF would be clearer.
A useful test is ownership: if a rule must remain correct even when a request bypasses the public gateway through an internal channel, that rule belongs in the downstream service too.
API Gateway, BFF, and Service Mesh Are Different Boundaries
These components can coexist because they solve different problems.
| Component | Primary traffic | Main responsibility |
|---|---|---|
| API Gateway | External to internal | Routing, authentication, edge policy, public contracts |
| Backend for Frontend | Client-specific | Aggregation and response shaping for one client experience |
| Service Mesh | Service to service | Internal traffic policy, mTLS, telemetry, retries, traffic shifting |
A common request path is:
Client
-> external load balancer
-> API Gateway
-> client-specific BFF
-> internal services
A smaller system can combine the gateway and a limited BFF endpoint in one deployment. If aggregation becomes complex, independently scaled, or owned by a frontend team, split it into a dedicated service.
Use a Compatible Spring Release Train
Spring Cloud versions are tied to Spring Boot generations. Do not select a Spring Boot version and an unrelated Spring Cloud snapshot independently.
At the time of this revision:
- Spring Boot
4.0.7is a stable Spring Boot 4.0 release; - Spring Cloud
2025.1.2supports Spring Boot 4.0.x; - Spring Cloud Gateway
5.0.2is the stable Gateway line managed by that release train.
Use the Spring Cloud BOM so that Gateway, LoadBalancer, CircuitBreaker, and other Spring Cloud modules remain aligned.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.7</version>
<relativePath />
</parent>
<groupId>com.example</groupId>
<artifactId>edge-gateway</artifactId>
<version>1.0.0</version>
<properties>
<java.version>25</java.version>
<spring-cloud.version>2025.1.2</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The Gateway WebFlux starter already brings the reactive server foundation. Adding a second generic WebFlux starter is usually unnecessary.
Spring Cloud Gateway Server WebFlux runs on Reactor Netty. It is not a traditional Servlet WAR application, and synchronous Servlet assumptions should not be copied into custom filters.
Configure Routes Explicitly
A route contains:
- an identifier;
- a target URI;
- predicates that decide whether the route matches;
- filters that modify or protect the exchange.
Current Gateway configuration places WebFlux routes under spring.cloud.gateway.server.webflux.routes.
spring:
application:
name: edge-gateway
cloud:
gateway:
server:
webflux:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
- RemoveRequestHeader=X-Internal-User
- name: CircuitBreaker
args:
name: userService
fallbackUri: forward:/fallback/users
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- RemoveRequestHeader=X-Internal-User
- name: Retry
args:
retries: 2
methods: GET
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT
backoff:
firstBackoff: 100ms
maxBackoff: 500ms
factor: 2
basedOnPreviousValue: false
loadbalancer:
retry:
enabled: false
server:
port: 8080
shutdown: graceful
With StripPrefix=1, this request:
GET /api/users/42
is sent downstream as:
GET /users/42
Use one path convention consistently. Mixing RewritePath, controller mappings, ingress rewrites, and downstream context paths makes routing failures difficult to diagnose.
Service discovery is optional
The lb:// scheme uses Spring Cloud LoadBalancer and a DiscoveryClient implementation. Depending on the platform, the registry might be Eureka, Consul, or Kubernetes discovery.
For a small Docker Compose environment, a direct target is simpler:
uri: http://user-service:8081
For Kubernetes, a stable service DNS name is often enough:
uri: http://user-service.default.svc.cluster.local
Do not introduce a service registry only because an example uses one. Select the discovery mechanism that matches the deployment platform.
Configure Timeouts Before Adding Retries
A gateway request without a bounded timeout can hold resources while a downstream dependency remains unavailable.
Spring Cloud Gateway supports global and per-route HTTP timeouts.
spring:
cloud:
gateway:
httpclient:
connect-timeout: 1000
response-timeout: 3s
The global connect timeout is expressed in milliseconds, while the global response timeout accepts a duration.
A route can override both through metadata:
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: report-service
uri: lb://report-service
predicates:
- Path=/api/reports/**
metadata:
connect-timeout: 500
response-timeout: 8000
Timeout values should come from an explicit latency budget:
Client timeout
> Gateway processing
+ Gateway-to-service timeout
+ fallback or retry allowance
+ network margin
If the gateway can retry twice with a three-second response timeout, the client timeout must account for the worst-case path—or the client will give up while the gateway continues working.
Retry Only When the Operation Is Safe
Retries can improve availability for transient failures, but they can also repeat side effects.
Safer retry candidates include:
GETandHEADrequests;- idempotent operations protected by an idempotency key;
- connection failures before the downstream service receives the request;
- specific
502or504responses.
Avoid automatic edge retries for ordinary POST requests unless the downstream contract explicitly guarantees idempotency.
A payment request illustrates the danger:
Gateway sends POST /payments
Payment service commits the charge
Response is lost
Gateway retries
Payment is charged twice
A correct design gives the client request a stable idempotency key and requires the payment service to enforce uniqueness. The gateway retry is not a replacement for downstream idempotency.
Use Circuit Breakers for Controlled Degradation
The modern Gateway filter is CircuitBreaker, backed by Spring Cloud CircuitBreaker and commonly Resilience4j. Do not use the retired Hystrix filter.
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: catalog-service
uri: lb://catalog-service
predicates:
- Path=/api/catalog/**
filters:
- name: CircuitBreaker
args:
name: catalogService
fallbackUri: forward:/fallback/catalog
resilience4j:
circuitbreaker:
instances:
catalogService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 5
A fallback should be explicit about degraded data. Do not return a normal-looking response that silently omits critical fields.
@RestController
@RequestMapping("/fallback")
public class GatewayFallbackController {
@GetMapping("/catalog")
public ResponseEntity<ProblemDetail> catalogFallback() {
ProblemDetail problem = ProblemDetail.forStatus(
HttpStatus.SERVICE_UNAVAILABLE
);
problem.setTitle("Catalog temporarily unavailable");
problem.setDetail(
"The catalog service did not respond within the edge latency budget."
);
problem.setProperty("degraded", true);
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(problem);
}
}
Fallbacks are suitable for optional content, cached summaries, or explicit service-unavailable responses. They should not fabricate successful financial, inventory, or authorization outcomes.
Authenticate at the Edge, Authorize in Depth
The gateway can validate a JWT before routing a request. Downstream services should still enforce authorization for their own resources and invariants.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://identity.example.com/realms/platform
@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {
@Bean
SecurityWebFilterChain gatewaySecurity(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchange -> exchange
.pathMatchers(
"/actuator/health",
"/actuator/info",
"/public/**"
).permitAll()
.pathMatchers(HttpMethod.GET, "/api/catalog/**")
.hasAuthority("SCOPE_catalog.read")
.pathMatchers("/api/orders/**")
.hasAuthority("SCOPE_orders.write")
.anyExchange()
.authenticated()
)
.oauth2ResourceServer(resourceServer ->
resourceServer.jwt(Customizer.withDefaults())
)
.build();
}
}
Do not trust identity headers from clients
A client can send a header such as:
X-Internal-User: admin
unless the edge explicitly removes it.
Safer options include:
- forward the validated bearer token and let downstream resource servers validate it;
- use token relay when the gateway acts as an OAuth 2.0 client;
- strip inbound identity headers and create new signed or network-trusted headers;
- protect the gateway-to-service network with mTLS or equivalent workload identity.
If a downstream service trusts a gateway-generated identity header, it must not be reachable through a path where a client can provide that header directly.
Rate Limiting Needs a Stable Key
Rate limiting can protect:
- login and token endpoints;
- expensive search routes;
- partner APIs with contractual quotas;
- endpoints exposed to automated abuse;
- dependencies with a known capacity ceiling.
A KeyResolver can derive the key from an authenticated principal:
@Configuration
public class RateLimitConfiguration {
@Bean
KeyResolver principalKeyResolver() {
return exchange -> exchange
.getPrincipal()
.map(Principal::getName)
.switchIfEmpty(
Mono.just(
exchange.getRequest()
.getRemoteAddress()
.getAddress()
.getHostAddress()
)
);
}
}
With the Redis rate limiter dependency and Redis connection configured, a route can use:
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: search-service
uri: lb://search-service
predicates:
- Path=/api/search/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@principalKeyResolver}"
redis-rate-limiter.replenishRate: 20
redis-rate-limiter.burstCapacity: 40
redis-rate-limiter.requestedTokens: 1
IP-based limiting alone is weak when many users share a NAT address or attackers rotate source addresses. Prefer an authenticated tenant, account, API key, or client identifier when available.
Keep Body Transformation Small and Typed
String replacement is not safe JSON transformation:
body.replace("}", ", \"processedByGateway\": true}")
It can break nested objects, arrays, whitespace, escaped values, or non-JSON responses.
Use typed deserialization for small transformations:
public record ProductResponse(
String id,
String name,
BigDecimal price
) {}
public record ClientProductResponse(
String id,
String displayName,
BigDecimal price
) {}
For large bodies, streaming responses, file downloads, or high-volume routes, avoid gateway-side body transformation. Buffering a response increases memory use and delays the first byte sent to the client.
When the transformation represents a client-specific contract rather than generic edge policy, a BFF is usually the clearer home.
Implement BFF Composition as an Application Boundary
A dashboard endpoint often needs data from multiple services. Hiding that orchestration inside a custom GatewayFilter with a dummy no://op URI makes error handling and response writing harder than necessary.
For a limited aggregation endpoint in the same deployment, use an ordinary reactive controller and a dedicated service. Do not define a Gateway route for the same path.
public record UserView(
String id,
String name,
String email
) {}
public record OrderView(
String orderId,
BigDecimal total,
Instant createdAt
) {}
public record DashboardResponse(
UserView user,
List<OrderView> recentOrders,
List<String> warnings
) {}
@Configuration
public class DownstreamClientConfiguration {
@Bean
WebClient userClient(WebClient.Builder builder) {
return builder
.baseUrl("http://user-service:8081")
.build();
}
@Bean
WebClient orderClient(WebClient.Builder builder) {
return builder
.baseUrl("http://order-service:8082")
.build();
}
}
@Service
public class DashboardService {
private final WebClient userClient;
private final WebClient orderClient;
public DashboardService(
@Qualifier("userClient") WebClient userClient,
@Qualifier("orderClient") WebClient orderClient
) {
this.userClient = userClient;
this.orderClient = orderClient;
}
public Mono<DashboardResponse> load(String userId) {
Mono<UserView> requiredUser = userClient
.get()
.uri("/users/{userId}", userId)
.retrieve()
.bodyToMono(UserView.class);
Mono<OrderResult> optionalOrders = orderClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/orders")
.queryParam("userId", userId)
.queryParam("limit", 10)
.build()
)
.retrieve()
.bodyToFlux(OrderView.class)
.collectList()
.map(orders -> new OrderResult(
orders,
List.<String>of()
))
.onErrorResume(exception ->
Mono.just(
new OrderResult(
List.of(),
List.of(
"Recent orders are temporarily unavailable"
)
)
)
);
return Mono.zip(requiredUser, optionalOrders)
.map(tuple -> new DashboardResponse(
tuple.getT1(),
tuple.getT2().orders(),
tuple.getT2().warnings()
));
}
private record OrderResult(
List<OrderView> orders,
List<String> warnings
) {}
}
@RestController
@RequestMapping("/api/dashboard")
public class DashboardController {
private final DashboardService dashboardService;
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
@GetMapping("/{userId}")
Mono<DashboardResponse> dashboard(
@PathVariable String userId
) {
return dashboardService.load(userId);
}
}
This design makes the failure policy visible:
- user data is required, so failure fails the request;
- recent orders are optional, so failure produces an empty list and a warning;
Mono.zipstarts the independent requests without blocking;- response construction remains testable outside the low-level gateway filter chain.
For a larger BFF, move this controller and service to a separate deployment with its own scaling, release cycle, and ownership.
Where Java Virtual Threads Fit
Spring Cloud Gateway Server WebFlux uses Reactor and Netty. Its event-loop model already avoids dedicating a platform thread to every waiting network request.
Virtual threads therefore are not a switch that makes the reactive gateway faster.
They are useful when an application intentionally uses a blocking programming model, such as:
- a downstream Spring MVC service that calls JDBC;
- a separate imperative BFF;
- a compatibility adapter for a blocking SDK;
- administrative tasks that do not run on the Netty event loop.
Java virtual threads help blocking I/O scale with a thread-per-task style. They do not make CPU-intensive work faster, and they do not make blocking on the Netty event loop safe.
Inside a reactive Gateway filter, this is dangerous:
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
GatewayFilterChain chain
) {
legacyBlockingClient.loadCustomer(); // Blocks the event loop
return chain.filter(exchange);
}
The preferred options are:
- use a non-blocking client;
- move the integration behind a downstream adapter service;
- isolate unavoidable blocking work on a scheduler designed for blocking tasks;
- use a separate imperative BFF built around virtual threads.
Do not maintain two concurrency models in the gateway without a concrete reason and measurements.
Observability at the Edge
A gateway should answer four operational questions:
- Which route handled the request?
- How long did routing and the downstream call take?
- Which dependency or policy caused the failure?
- Can the request be traced across services?
Spring Boot Actuator and Micrometer provide the foundation for metrics and tracing.
Useful gateway measurements include:
- request count by route and status;
- latency percentiles by route;
- active requests;
- connection pool saturation;
- circuit-breaker state and rejection count;
- rate-limit rejection count;
- downstream timeout count;
- retry count;
- response size;
- authentication failure count.
Avoid high-cardinality metric tags such as raw user IDs, full URLs with identifiers, bearer tokens, or arbitrary query strings.
For logs, include a correlation or trace ID and route ID, but do not record authorization headers, session cookies, passwords, or unfiltered request bodies.
Protect Actuator Endpoints
Gateway actuator endpoints can expose route definitions and operational details. Do not publish every endpoint to the public internet.
A conservative configuration is:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
probes:
enabled: true
show-details: never
Expose administrative Gateway route inspection only on a private management network with authentication.
Deployment and Scaling
Keep the gateway stateless so multiple replicas can run behind a load balancer.
Production considerations include:
- at least two replicas across failure domains;
- readiness probes that stop traffic before shutdown;
- graceful shutdown with a sufficient drain period;
- bounded connection pools and pending acquisition queues;
- route configuration versioning;
- secret management outside source control;
- separate dashboards for gateway and downstream latency;
- load tests that include slow and failing dependencies;
- resource limits that leave headroom for network buffers.
Do not deploy the gateway as a Kubernetes StatefulSet unless it genuinely owns stable identity or storage. A stateless Deployment is the normal choice.
Failure Policy Matrix
Write the edge policy before implementing filters.
| Failure | Typical gateway response | Retry? | Fallback? |
|---|---|---|---|
| Invalid or missing JWT | 401 Unauthorized | No | No |
| Authenticated but forbidden | 403 Forbidden | No | No |
| Rate limit exceeded | 429 Too Many Requests | Client-controlled | No |
Downstream connection failure on GET | 502 Bad Gateway | Limited retry | Possibly |
| Downstream response timeout | 504 Gateway Timeout | Limited and budgeted | Possibly |
| Invalid client payload | 400 Bad Request | No | No |
| Downstream domain rejection | Preserve suitable 4xx | No | No |
| Circuit open | 503 Service Unavailable | No immediate retry | Explicit degraded response |
| Gateway internal error | 500 Internal Server Error | No automatic retry | No |
A fallback is a product decision, not just a technical switch. The client contract must explain whether data is stale, partial, or unavailable.
Testing Strategy
Route tests
Verify:
- the expected route matches;
- path rewriting is correct;
- blocked headers are removed;
- public and protected paths follow security rules;
- fallback routes return the documented status.
Resilience tests
Simulate:
- connection refusal;
- slow response beyond the route timeout;
- intermittent
502responses; - an open circuit breaker;
- exhausted rate-limit tokens;
- a client disconnect during downstream processing.
BFF tests
Verify:
- independent calls run concurrently;
- required dependency failure fails the response;
- optional dependency failure produces the warning contract;
- empty collections remain valid JSON arrays;
- cancellation propagates to outstanding reactive calls where possible.
Use WebTestClient for the gateway surface and stub HTTP servers or containers for downstream dependencies. Do not mock every network boundary and assume the route configuration works.
Troubleshooting
A route returns 404
Check:
- whether the path predicate matches the actual request;
- whether a controller and Gateway route conflict;
- whether route configuration uses the current property namespace;
- whether the configuration profile was loaded;
- whether the route appears in the private Gateway actuator view.
A route returns 503 with lb://
Check:
- whether a
DiscoveryClientimplementation is configured; - whether the service ID matches registration;
- whether healthy instances are available;
- whether
spring-cloud-starter-loadbalanceris present; - whether DNS or registry access works from the gateway container.
Throughput collapses under load
Look for:
- blocking SDK calls on Reactor Netty threads;
- response body buffering;
- unbounded logging;
- connection pool exhaustion;
- retries multiplying downstream load;
- CPU-heavy transformations;
- a slow identity provider or JWK endpoint;
- rate limiter or Redis latency.
The circuit breaker never opens
Check:
- whether the CircuitBreaker starter is on the classpath;
- whether the filter wraps the intended route;
- whether failures match the configured threshold;
- whether the minimum number of calls has been reached;
- whether retries are hiding or multiplying failures.
JWT authentication works at the gateway but fails downstream
Decide which trust model is intended:
- forward the bearer token and validate it again downstream;
- relay an OAuth access token;
- exchange it for a service-specific token;
- generate trusted identity headers and prevent all bypass paths.
Do not accidentally mix these models.
Conclusion
A production API Gateway should be boring in the best sense: predictable routes, bounded latency, explicit security, controlled degradation, and measurable behavior.
For a Spring Boot 4 and Spring Cloud Gateway design:
- use a compatible Spring Cloud release train;
- use the Gateway Server WebFlux starter and current route namespace;
- keep blocking work off the Netty event loop;
- configure timeouts before retries;
- retry only safe or explicitly idempotent operations;
- use the CircuitBreaker filter rather than retired Hystrix examples;
- validate JWTs at the edge while preserving downstream authorization;
- move complex client composition into a visible BFF boundary;
- treat virtual threads as a tool for blocking applications, not a reactive gateway accelerator;
- test failure windows, not only successful routing.
The gateway should reduce complexity for clients without concentrating every form of complexity inside itself.