API Gateway with Spring Cloud Gateway
Building an edge gateway: route predicates, the filter catalogue, custom global filters for auth and correlation, Redis rate limiting, and circuit breakers at the edge.
On this page
An API gateway is the single front door to a set of services. It is the natural place for the concerns every service would otherwise reimplement — and, if you are not careful, the natural place for a distributed monolith to accumulate.
Key Takeaways
- A route is predicates plus filters plus a destination URI. That is the whole model.
lb://service-nameroutes through service discovery with client-side load balancing.GlobalFilterapplies to every route; aGatewayFilterapplies to one.- The gateway is reactive — a blocking call there stalls the entire event loop.
- Keep business logic out, or the gateway becomes everyone's release bottleneck.
Routes
spring:
cloud:
gateway:
default-filters:
- AddResponseHeader=X-Gateway, acme-edge
- RemoveRequestHeader=X-Internal-Trusted # strip client attempts to forge it
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/v1/orders/**
filters:
- StripPrefix=2
- name: CircuitBreaker
args:
name: ordersCircuitBreaker
fallbackUri: forward:/fallback/orders
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
redis-rate-limiter.requestedTokens: 1
key-resolver: '#{@userKeyResolver}'
- name: Retry
args:
retries: 2
# Only idempotent methods. Retrying a POST after a timeout can
# create a duplicate order.
methods: GET
series: SERVER_ERROR
backoff:
firstBackoff: 100ms
maxBackoff: 1s
factor: 2
- id: catalogue
uri: lb://catalogue-service
predicates:
- Path=/api/v1/catalogue/**
- Method=GET
filters:
- StripPrefix=2
- AddResponseHeader=Cache-Control, "public, max-age=300"
- id: partner
uri: lb://partner-api
predicates:
- Host=partner.acme.com
- Header=X-Partner-Key, .+The RemoveRequestHeader in default-filters is a small line doing important work. If any downstream
service trusts a header the gateway sets — X-User-Id, for example — then a client that sends that
header directly must not have it pass through. Stripping inbound copies of every internal header is
the only reliable defence.
Predicates
| Predicate | Example |
|---|---|
Path | Path=/api/v1/orders/** |
Method | Method=GET,HEAD |
Host | Host=**.acme.com |
Header | Header=X-Api-Version, 2 |
Query | Query=beta, true |
Cookie | Cookie=experiment, variant-b |
RemoteAddr | RemoteAddr=10.0.0.0/8 |
Weight | Weight=orders, 90 |
Weight is how you do canary routing at the edge: two routes to the same path with weights 90 and 10
send a tenth of traffic to a new version, with no service mesh required.
Custom global filters
@Component
public class CorrelationIdGlobalFilter implements GlobalFilter, Ordered {
private static final String HEADER = "X-Correlation-Id";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String id = Optional.ofNullable(exchange.getRequest().getHeaders().getFirst(HEADER))
.filter(StringUtils::hasText)
.orElseGet(() -> UUID.randomUUID().toString());
ServerWebExchange mutated = exchange.mutate()
.request(r -> r.header(HEADER, id))
.build();
mutated.getResponse().getHeaders().set(HEADER, id);
// Reactive: there is no ThreadLocal to write to. Put it in the Reactor
// context, and let the logging bridge pick it up.
return chain.filter(mutated)
.contextWrite(Context.of(HEADER, id));
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // first, so every later filter can log it
}
}@Component
public class AuthenticationGlobalFilter implements GlobalFilter, Ordered {
private final ReactiveJwtDecoder decoder;
private final List<PathPattern> publicPaths;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
var request = exchange.getRequest();
if (publicPaths.stream().anyMatch(p -> p.matches(request.getPath().pathWithinApplication()))) {
return chain.filter(exchange);
}
String header = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
return unauthorized(exchange, "missing bearer token");
}
return decoder.decode(header.substring(7))
.flatMap(jwt -> {
// Forward identity downstream so services do not re-decode.
// They still validate the token themselves — this is a
// convenience, never a trust boundary.
var mutated = exchange.mutate()
.request(r -> r
.header("X-User-Id", jwt.getSubject())
.header("X-User-Scopes", String.join(" ", scopesOf(jwt))))
.build();
return chain.filter(mutated);
})
.onErrorResume(JwtException.class,
ex -> unauthorized(exchange, "invalid token"));
}
@Override
public int getOrder() { return -100; }
}That comment matters. A downstream service that trusts X-User-Id without validating the token is
one misconfigured network policy away from full impersonation — anyone who can reach the service
directly can set the header. Gateway validation is an optimisation that rejects bad traffic early;
it is not authentication for the services behind it.
Rate limiting
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-User-Id"))
// Fall back to IP for unauthenticated traffic, which is exactly
// where limiting matters most — login and registration.
.switchIfEmpty(Mono.justOrEmpty(
exchange.getRequest().getRemoteAddress())
.map(addr -> addr.getAddress().getHostAddress()))
.defaultIfEmpty("anonymous");
}RedisRateLimiter implements a token bucket in a Lua script, so the limit is shared across every
gateway instance. replenishRate is the sustained rate per second and burstCapacity is how much a
client may spend at once — set burst to roughly twice the replenish rate to tolerate normal
burstiness without allowing a client to consume a whole minute's allowance instantly.
Circuit breaking and fallbacks
@RestController
public class FallbackController {
@RequestMapping("/fallback/orders")
public ResponseEntity<ProblemDetail> ordersFallback() {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.SERVICE_UNAVAILABLE,
"The order service is temporarily unavailable. Please retry shortly.");
problem.setType(URI.create("https://api.acme.com/errors/service-unavailable"));
problem.setTitle("Service unavailable");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, "30")
.body(problem);
}
}A gateway-level breaker protects the whole estate. When the order service is down, the gateway stops forwarding requests to it, which means the order service gets a chance to recover instead of being held under load by retrying clients.
What not to put in a gateway
The gateway is shared infrastructure, and everything added to it becomes a change every team must coordinate around. Three things in particular tend to creep in and should be resisted.
Payload transformation. ModifyRequestBody and ModifyResponseBody exist and are occasionally
necessary for legacy adaptation, but each use couples the gateway to a service's schema. Now a field
rename requires a gateway deploy.
Aggregation. Calling three services and merging the results belongs in a backend-for-frontend service that a product team owns, not in shared edge infrastructure.
Business rules. The moment the gateway knows that orders over a certain value need approval, it has become part of the domain and every domain change queues behind the platform team's release schedule.
The test: could a team change their service's behaviour without a gateway deploy? If not, something has leaked into the wrong place.
Operational notes
Enable /actuator/gateway/routes to list the effective routing table — the fastest way to answer
"why is this request going there". Watch spring.cloud.gateway.requests tagged by routeId and
outcome, which gives per-route latency and error rate on one graph.
Set request size limits with the RequestSize filter so a large upload cannot exhaust gateway
memory, and configure httpclient.response-timeout so a stalled downstream does not hold connections
indefinitely. And remember the reactive constraint: no blocking calls, no JDBC, no
RestTemplate, nothing that parks a thread — the event loop is small and shared by every request.
What to take away
Model routes as predicates plus filters, use lb:// for discovery-aware load balancing, and put
correlation, authentication, rate limiting and circuit breaking in global filters. Strip inbound
copies of internal headers, keep services validating tokens themselves, and keep business logic out
of the gateway entirely.
Frequently Asked Questions
Can I use blocking code in a gateway filter?
Should the gateway validate JWTs, or should each service?
What belongs in a gateway and what does not?
Related tutorials
- Service Discovery & RegistrationClient-side versus server-side discovery, running Eureka properly including self-preservation, Consul as an alternative, and why Kubernetes usually makes a separate registry unnecessary.
- Inter-Service Communication PatternsChoosing how services talk: synchronous REST and gRPC versus asynchronous messaging, the coupling each creates, correlation propagation, and graceful degradation.
- Microservices Decomposition PatternsFinding service boundaries that hold: decomposing by business capability and subdomain, context mapping patterns, the anti-corruption layer, and the strangler fig migration.
- Circuit Breakers & Resilience4jResilience4j in production: how the circuit breaker state machine works, tuning the sliding window, combining retry and bulkhead correctly, and the decorator order that matters.