Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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-name routes through service discovery with client-side load balancing.
  • GlobalFilter applies to every route; a GatewayFilter applies 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

Predicates select a route, filters transform the exchange, and the URI names the destination. Global filters apply to all of them.
application.yml
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

PredicateExample
PathPath=/api/v1/orders/**
MethodMethod=GET,HEAD
HostHost=**.acme.com
HeaderHeader=X-Api-Version, 2
QueryQuery=beta, true
CookieCookie=experiment, variant-b
RemoteAddrRemoteAddr=10.0.0.0/8
WeightWeight=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

CorrelationIdGlobalFilter.java
@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
    }
}
AuthenticationGlobalFilter.java
@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

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

FallbackController.java
@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?
No. Spring Cloud Gateway runs on Netty with a small event loop, and one blocking call stalls every request sharing that thread. Use WebClient for I/O, and if you must call blocking code, wrap it in Mono.fromCallable with subscribeOn(Schedulers.boundedElastic()).
Should the gateway validate JWTs, or should each service?
Both, for different reasons. The gateway rejects obviously invalid tokens early so bad traffic never reaches your services. Each service still validates, because a gateway compromise or a direct internal call must not bypass authentication. Never let a service trust a header the gateway claims to have verified.
What belongs in a gateway and what does not?
Cross-cutting edge concerns: routing, authentication, rate limiting, CORS, correlation IDs, request size limits, TLS termination. Business logic does not belong there. A gateway that transforms payloads or makes decisions about orders becomes a deployment bottleneck every team has to queue behind.

Related tutorials