As you add more microservices, cross-cutting concerns like authentication, CORS, rate limiting, and access logging tend to get copy-pasted and scattered across every service. Subtle differences in each implementation can become a breeding ground for bugs.

The standard approach is to consolidate this shared logic into an “API gateway” placed in front of each service. In this article, we’ll use Spring Cloud Gateway to build a gateway—with working code—that brings routing, authentication, rate limiting, and resilience together in one place.

The Three Core Concepts of Spring Cloud Gateway

Spring Cloud Gateway is a reverse proxy that dispatches incoming requests to downstream services based on conditions. To understand its configuration, there are just three concepts you need to grasp.

  • Route is a single rule that says, “if this condition matches, send the request to this destination.”
  • Predicate is the match condition, evaluated by path, host, HTTP method, header, and so on.
  • Filter modifies requests or responses, injecting things like header addition, path rewriting, or authentication.

A request is processed in this flow: “select a route via Predicate → pass through the Filter chain → forward downstream → the response passes back through the Filters on its way out.” Keeping this order in mind makes the configuration much easier to read.

Note: WebFlux Is a Prerequisite

There’s one important prerequisite to cover first. Spring Cloud Gateway runs on the reactive stack (Netty + WebFlux). This means that if you add spring-boot-starter-web (Tomcat + Spring MVC) to the same project, the server stacks will conflict and startup will fail.

Remember that a gateway should generally stand as a standalone application, without the MVC starter. If you’re not familiar with reactive programming, reading Getting Started with Reactive Programming in Spring WebFlux alongside this article will help you get up to speed faster.

Because Spring Boot and Spring Cloud have a fixed compatibility relationship for versions, don’t pick a Spring Cloud generation arbitrarily—align them via the BOM to be safe.

Adding the Dependencies

With Maven, import the Spring Cloud BOM in dependencyManagement and add the starter. Here we assume Spring Cloud 2025.0.0 (Northfields).

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2025.0.0</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>
</dependencies>

In 2025.0.0, the starter names were reorganized, and the reactive version became spring-cloud-starter-gateway-server-webflux. The old spring-cloud-starter-gateway remains as an alias, but it emits a deprecation warning in the logs, so use the new name (the old name will be removed in the next generation, 2025.1.0). This alone is enough to launch a Netty-based gateway.

Defining Routes in application.yml

Let’s start with declarative configuration. Here’s an example that dispatches /api/users/** to the user service and /api/orders/** to the order service. The property prefix used is spring.cloud.gateway.server.webflux.routes.

spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: user-service
              uri: http://localhost:8081
              predicates:
                - Path=/api/users/**
                - Method=GET,POST
              filters:
                - AddRequestHeader=X-Gateway, scg
            - id: order-service
              uri: http://localhost:8082
              predicates:
                - Path=/api/orders/**
                - Header=X-Api-Version, v1

uri is the forwarding destination. In addition to specifying it directly with http://, if you combine it with service discovery you can write something like lb://user-service to route through load balancing. Writing multiple predicates combines them as an AND condition.

Note that the old prefix spring.cloud.gateway.routes still works in 2025.0.0 but is treated as deprecated and emits a warning. Temporarily adding spring-boot-properties-migrator lets you flush out old prefixes.

Building Routes Dynamically with the Java DSL

If you need conditional branching or environment-specific routing, the Java DSL—which defines a RouteLocator as a Bean—is convenient. You can also use it together with YAML.

@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("user-service", r -> r
            .path("/api/users/**")
            .filters(f -> f.stripPrefix(2))
            .uri("http://localhost:8081"))
        .route("order-service", r -> r
            .path("/api/service-b/**")
            .filters(f -> f.rewritePath("/api/service-b/(?<seg>.*)", "/${seg}"))
            .uri("http://localhost:8082"))
        .build();
}

Absorbing Path Differences

The path you expose externally and the path the downstream service actually receives are usually different. StripPrefix and RewritePath are what bridge this gap.

StripPrefix=2 removes the first two segments. /api/users/123 arrives downstream as /123. When you want more flexible transformations, use RewritePath’s regular expressions. In the DSL example above, /api/service-b/foo is rewritten to /foo. Designing your prefixes like /api/{service-name}/** keeps your routes clear and easy to follow.

The Difference Between GatewayFilter and GlobalFilter

There are two kinds of filters: GatewayFilter, applied only to specific routes, and GlobalFilter, applied uniformly to all routes. Logic you want to apply to every request—like authentication or access logging—is written as a GlobalFilter.

For both, everything before calling chain.filter(exchange) is the pre-processing, and everything after it returns (inside then) is the post-processing. The order of multiple filters is controlled by implementing Ordered and using the value from getOrder(). The smaller the value, the earlier it runs.

Implementing Cross-Cutting Token Verification with a GlobalFilter

Let’s inject an authentication check common to all requests. We extract the Authorization header, and if verification fails, we return a 401 and stop the chain.

@Component
public class AuthFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest()
            .getHeaders().getFirst(HttpHeaders.AUTHORIZATION);

        if (token == null || !isValid(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // Propagate the verified user information downstream
        ServerHttpRequest mutated = exchange.getRequest().mutate()
            .header("X-User-Id", extractUserId(token))
            .build();
        return chain.filter(exchange.mutate().request(mutated).build());
    }

    @Override
    public int getOrder() {
        return -100; // Run before rate limiting and the like
    }
}

This shows only the skeleton of token verification. Building the OAuth2/OIDC authorization server itself is outside the scope of this article, so it’s cleanest to limit the gateway’s role to “verify and pass the result downstream.”

Consolidating Access Logs

Logging can also be centralized with a GlobalFilter. We record the status and elapsed time in the post-processing. One thing to watch out for here is that at the time of post-processing, getStatusCode() can return null. To ensure that log processing doesn’t break the main request flow, extract the value in a null-safe way.

@Component
public class AccessLogFilter implements GlobalFilter {
    private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        long start = System.currentTimeMillis();
        String path = exchange.getRequest().getURI().getPath();
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            long took = System.currentTimeMillis() - start;
            HttpStatusCode code = exchange.getResponse().getStatusCode();
            int status = code != null ? code.value() : -1;
            log.info("{} status={} {}ms", path, status, took);
        }));
    }
}

When you want to attach a trace ID to your logs for tracking, combining this with Achieving Distributed Tracing with Micrometer Tracing and Zipkin lets you see the flow across services.

Applying Rate Limiting with RequestRateLimiter

Rate limiting can also be consolidated at the gateway layer. Spring Cloud Gateway comes with a Redis-based token bucket RequestRateLimiter out of the box. First add spring-boot-starter-data-redis-reactive, then configure the filter.

spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: user-service
              uri: http://localhost:8081
              predicates:
                - Path=/api/users/**
              filters:
                - name: RequestRateLimiter
                  args:
                    redis-rate-limiter.replenishRate: 10
                    redis-rate-limiter.burstCapacity: 20
                    redis-rate-limiter.requestedTokens: 1
                    key-resolver: "#{@ipKeyResolver}"

replenishRate is the number of tokens replenished per second (i.e., the steady-state allowed rate), and burstCapacity is the bucket’s upper limit (i.e., the burst allowed momentarily). The unit of limiting is determined by the KeyResolver. To limit per IP, write it like this.

@Bean
public KeyResolver ipKeyResolver() {
    return exchange -> {
        InetSocketAddress addr = exchange.getRequest().getRemoteAddress();
        String key = addr != null ? addr.getAddress().getHostAddress() : "unknown";
        return Mono.just(key);
    };
}

getRemoteAddress() can return null when behind a proxy or load balancer, or it may return the address of the upstream LB rather than the real client. In production, lean toward an implementation that gets the real IP from the X-Forwarded-For header, or consider enabling ForwardedHeaderTransformer. If you make it return the X-User-Id or an API key header, you can quickly switch to per-user or per-key limiting.

If you want fine-grained control as Java code within a single application, Implementing In-App Rate Limiting with Bucket4j is a good fit. It’s wise to use the gateway layer and the application layer for their respective strengths.

Falling Back with Resilience4j

When a downstream service goes down, you want to avoid the gateway getting dragged down with it by waiting endlessly for a timeout. Adding spring-cloud-starter-circuitbreaker-reactor-resilience4j gives you access to the CircuitBreaker filter.

filters:
  - name: CircuitBreaker
    args:
      name: userCb
      fallbackUri: forward:/fallback/users

When the downstream is unhealthy, the request flows to the internal endpoint specified in fallbackUri, where you can return an alternative response (a cache or a degraded message). When you want to fine-tune the circuit breaker or retry behavior itself, refer to Implementing a Circuit Breaker with Resilience4j. If you call downstream services with a declarative HTTP client, Building a Declarative HTTP Client with OpenFeign also pairs well.

Don’t Block Inside Filters

Finally, here’s a caveat unique to the reactive runtime. Because gateway filters run on the event loop, writing blocking operations—like synchronous JDBC queries or synchronous HTTP calls—directly will clog the small number of threads and cause throughput to plummet.

As a rule, never .block() a Mono chain inside a filter. If you absolutely need synchronous processing, offload it to the boundedElastic scheduler.

return Mono.fromCallable(() -> legacyBlockingCall())
    .subscribeOn(Schedulers.boundedElastic())
    .flatMap(result -> chain.filter(exchange));

For token verification too, when querying an external service, the basic principle is to use a non-blocking means like WebClient rather than a synchronous client.

Conclusion

We’ve built an API gateway with Spring Cloud Gateway that consolidates routing, path rewriting, authentication, access logging, rate limiting, and resilience in one place. Each microservice can focus on its core business logic, while cross-cutting concerns are pushed to the gateway.

To recap the key points: assume WebFlux and don’t colocate the MVC starter, use the new starter name and the server.webflux prefix in 2025.0.0, and avoid blocking inside filters. From here, combine this with the resilience and tracing articles to grow it into a gateway that stands up to production operations.