HomeSpring BootSpring Cloud Gateway: Routing, Filters, Rate Limiting, Circuit Breaker and JWT Auth
Spring Boot

Spring Cloud Gateway: Routing, Filters, Rate Limiting, Circuit Breaker and JWT Auth

Spring Cloud Gateway (SCG) is the official API gateway for Spring Boot microservices. Built on Spring WebFlux and Project Reactor, it is non-blocking and reactive. It routes requests to downstream services using predicates (matching rules) and applies filters (pre/post processing) such as path rewriting, header manipulation, rate limiting and circuit breaking. This guide covers a working setup with route ordering, Redis rate limiting, Resilience4j fallbacks, request-ID logging and JWT authentication, plus the mistakes that most often break a gateway.

How Spring Cloud Gateway Works

Every request that reaches the gateway goes through the same pipeline. First the Gateway Handler Mapping compares the request against all routes, in order, and picks the first route whose predicates all match. Then the request runs through the pre-phase of the global filters and that route's filters, is forwarded to the route's uri, and the response travels back through the post-phase of the same filters. Three terms matter. A route is an id, a destination uri, a list of predicates and a list of filters. A predicate is a matching rule such as Path, Header or Method. A filter changes the request or response, or short-circuits it (for example by returning 401 or 429). Global filters apply to every route; route filters apply only to the route that declares them. Because the gateway is built on Netty and WebFlux, a single slow downstream call does not hold a thread. That is also why you must never block inside a filter (no JDBC calls, no Thread.sleep, no blocking HTTP clients). It is also why the gateway cannot run on the servlet stack: spring-boot-starter-web and the gateway starter conflict.
Text
Client
  │
  ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ Spring Cloud Gateway                                      │
│                                                           │
│  1. Handler Mapping: first route whose predicates match   │
│  2. Pre-filters:  global filters + route filters          │
│       (request id → logging → auth → rate limit → ...)    │
│  3. Proxy request to route uri (lb://service-name)        │
│  4. Post-filters: response headers, logging, fallback     │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
  │                                   ā–²
  ā–¼                                   │
Downstream service (found via Eureka / load balancer)

Setup and Dependencies

Spring Cloud Gateway replaces the older Netflix Zuul 1.x. It requires the reactive stack, so do not add spring-boot-starter-web alongside it. The Eureka client starter lets the gateway resolve lb:// URIs through service discovery. Two version details trip people up. First, the Spring Cloud release train must match your Spring Boot version: release train 2025.0.x pairs with Spring Boot 3.5.x, and 2025.1.x pairs with Spring Boot 4. Check the compatibility table on spring.io before choosing. Second, starting with release train 2025.0, the gateway artifacts were renamed: the starter is now spring-cloud-starter-gateway-server-webflux and the configuration prefix is spring.cloud.gateway.server.webflux.*. The old names still work for now but are deprecated, so new projects should use the new ones. The examples below use the new names.
XML
<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.6</version> <!-- use the latest 3.5.x patch -->
</parent>

<properties>
    <java.version>21</java.version>
    <!-- Must match your Boot version. 2025.0.x = Boot 3.5.x, 2025.1.x = Boot 4.x -->
    <spring-cloud.version>2025.0.2</spring-cloud.version>
</properties>

<dependencies>

    <!-- Spring Cloud Gateway (includes WebFlux — do NOT add spring-boot-starter-web): -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
    </dependency>

    <!-- Service discovery: resolves lb://service-name through Eureka: -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    <!-- Resilience4j circuit breaker for gateway fallback routes: -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
    </dependency>

    <!-- Redis-backed rate limiting (token-bucket state is stored in Redis): -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>

    <!-- Actuator: exposes /actuator/gateway/routes for inspection: -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

</dependencies>

<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>

Route Configuration (YAML)

A route is the core building block of Spring Cloud Gateway. Each route has an id, a uri (where to forward), a list of predicates (when to match) and a list of filters (what to do before and after forwarding). Routes are evaluated in order and the first matching route wins. This is the most common source of bugs: if a broad route such as Path=/api/users/** is declared before a narrower route for the same path (a canary route that also checks a header, for example), the narrow route can never match. Always put the most specific routes first, or give routes an explicit order value. Lower order numbers are evaluated first. The configuration below does both, so the intent is obvious. There is no need for a catch-all route to return 404. If no route matches, the gateway already responds with 404 Not Found. Note that this also applies when a path matches but the method does not: a POST to a route restricted to GET and HEAD returns 404, not 405.
yaml
# application.yml
server:
  port: 8080

spring:
  application:
    name: api-gateway

  cloud:
    gateway:
      server:
        webflux:

          # ── Global default filters (applied to every route) ─────────────
          # Prevents duplicate CORS headers when both the gateway and a
          # downstream service add them:
          default-filters:
            - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST

          routes:

            # ── Route 1: Header-based canary (MOST SPECIFIC, so it goes first) ──
            - id: user-service-canary
              uri: lb://user-service-v2
              order: 1
              predicates:
                - Path=/api/users/**
                - Header=X-Canary, true       # only when the request has X-Canary: true
              filters:
                - StripPrefix=1

            # ── Route 2: User Service (general route for the same path) ────────
            - id: user-service
              uri: lb://user-service          # lb:// = load-balanced via Eureka
              order: 2
              predicates:
                - Path=/api/users/**
              filters:
                - StripPrefix=1               # /api/users/1 is forwarded as /users/1

            # ── Route 3: Order Service ─────────────────────────────────────────
            - id: order-service
              uri: lb://order-service
              order: 2
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1

            # ── Route 4: Method predicate (read-only public catalog) ───────────
            # POST/PUT/DELETE to /api/catalog/** match no route and return 404.
            - id: catalog-service
              uri: lb://catalog-service
              order: 2
              predicates:
                - Path=/api/catalog/**
                - Method=GET,HEAD
              filters:
                - StripPrefix=1

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

management:
  endpoints:
    web:
      exposure:
        include: gateway, health, info   # /actuator/gateway/routes

Route Configuration (Java DSL)

Routes can also be defined in code with the RouteLocatorBuilder Java DSL. This is useful when route logic depends on runtime conditions, Spring beans or environment variables that are awkward to express in YAML. Two details are worth noting. The same ordering rule applies: the canary route is declared first and given a lower order value. And values computed inside the DSL, such as UUID.randomUUID(), are evaluated once when the bean is created, not once per request. A header filter that calls UUID.randomUUID() would therefore send the same ID on every request. Per-request values belong in a global filter, shown later in the Request ID filter section.
Java
@Configuration
public class GatewayRoutesConfig {

    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder) {
        return builder.routes()

            // Canary route: more specific, so it is ordered first:
            .route("user-service-canary", r -> r
                .order(1)
                .path("/api/users/**")
                .and()
                .header("X-Canary", "true")
                .filters(f -> f.stripPrefix(1))
                .uri("lb://user-service-v2"))

            // User Service route:
            .route("user-service", r -> r
                .order(2)
                .path("/api/users/**")
                .filters(f -> f
                    .stripPrefix(1)
                    .addRequestHeader("X-Gateway-Source", "api-gateway")) // static value: safe
                .uri("lb://user-service"))

            // Order Service route:
            .route("order-service", r -> r
                .order(2)
                .path("/api/orders/**")
                .filters(f -> f.stripPrefix(1))
                .uri("lb://order-service"))

            // Path rewrite: /api/v1/products/** becomes /products/**
            .route("product-service", r -> r
                .order(2)
                .path("/api/v1/products/**")
                .filters(f -> f
                    .rewritePath("/api/v1/(?<segment>.*)", "/${segment}"))
                .uri("lb://product-service"))

            .build();
    }
}

Built-in Filters: Path and Header Reference

Spring Cloud Gateway ships with a large library of built-in GatewayFilter factories. Filters run in two phases: pre-filters execute before the request is forwarded, and post-filters execute after the response is received. The reference below lists the most commonly used path and header filters. They are alternatives, not a recipe to combine on one route. A few clarifications. SetRequestHeader replaces a header, while AddRequestHeader appends a value. MapRequestHeader copies the value of an existing header into a new header name; it does not rename or remove the original. Filter arguments are static strings, so you cannot read a JWT claim or a response time through a placeholder in YAML. Dynamic values need a custom filter. In YAML, a regex replacement placeholder must be escaped as $\{segment}, otherwise Spring tries to resolve it as a property placeholder at startup.
yaml
# Reference only. Pick the filters your route needs.
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: filter-reference
              uri: lb://user-service
              predicates:
                - Path=/api/**
              filters:
                # ── Path filters ──────────────────────────────────────────────
                # - StripPrefix=1                 /api/users/1  ->  /users/1
                # - PrefixPath=/v2                /users/1      ->  /v2/users/1
                # - RewritePath=/api/(?<segment>.*), /$\{segment}
                #                                 /api/orders/5 ->  /orders/5
                # - SetPath=/fixed/{segment}      replaces the path with a template

                # ── Header filters ────────────────────────────────────────────
                - AddRequestHeader=X-Source, gateway
                - SetRequestHeader=X-Tenant, default
                - RemoveRequestHeader=Cookie
                - MapRequestHeader=X-Custom-In, X-Custom-Out   # copies the value to a new header
                - AddResponseHeader=X-Served-By, api-gateway
                - RemoveResponseHeader=X-Internal-Error-Detail
                - SetResponseHeader=Content-Type, application/json

                # ── Redirect / status filters ─────────────────────────────────
                # - RedirectTo=301, https://new-domain.com
                # - SetStatus=202

                # ── Request size limiter ──────────────────────────────────────
                - name: RequestSize
                  args:
                    maxSize: 5MB                  # reject bodies larger than 5 MB with 413

Retry Filter

The Retry filter re-sends a failed request to the downstream service. Only retry idempotent methods (GET and HEAD). Retrying a POST can create duplicate orders or payments. The backoff settings below wait 100 ms, then 200 ms, then 400 ms between attempts, capped at 1000 ms. Combine Retry with a circuit breaker so a failing service is not hammered with retries.
yaml
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: order-service
              uri: lb://order-service
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1
                - name: Retry
                  args:
                    retries: 3                   # retry up to 3 times
                    statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
                    methods: GET, HEAD           # idempotent methods only
                    backoff:
                      firstBackoff: 100ms
                      maxBackoff: 1000ms
                      factor: 2                  # exponential: 100ms, 200ms, 400ms

Rate Limiting: Redis Setup

Spring Cloud Gateway includes a Redis-backed RequestRateLimiter filter that implements the token-bucket algorithm. Tokens are replenished at a configurable rate. When a client runs out of tokens the gateway returns 429 Too Many Requests immediately, before the request reaches any downstream service. Because the bucket state lives in Redis, every gateway instance shares the same counters, which is what you want when running several replicas. Redis must be reachable, or rate limiting will fail.
yaml
# application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379

Rate Limiting: Key Resolvers

A KeyResolver decides which bucket a request belongs to: per user, per IP, per API key. If the resolver returns an empty key, the gateway denies the request with 403 by default, which is why both resolvers below provide a fallback value. The user-based resolver relies on exchange.getPrincipal(), which only returns a user when Spring Security is configured and the request is authenticated. Without Spring Security every request would fall into the "anonymous" bucket and all clients would share one limit. The minimal security configuration below validates JWTs with the OAuth2 resource server support, so the principal name is the token's sub claim. For the IP-based resolver, getRemoteAddress() returns the address of the last proxy when you run behind a load balancer, not the client. Use XForwardedRemoteAddressResolver and set maxTrustedIndex to the number of trusted proxies in front of the gateway.
Java
@Configuration
public class RateLimiterConfig {

    // Rate-limit per authenticated user (JWT sub claim). Requires Spring Security.
    @Bean
    @Primary
    public KeyResolver userKeyResolver() {
        return exchange -> exchange.getPrincipal()
            .map(Principal::getName)
            .defaultIfEmpty("anonymous");
    }

    // Rate-limit per client IP, correct behind one trusted load balancer:
    @Bean
    public KeyResolver ipKeyResolver() {
        XForwardedRemoteAddressResolver resolver =
            XForwardedRemoteAddressResolver.maxTrustedIndex(1);
        return exchange -> Mono.justOrEmpty(resolver.resolve(exchange))
            .map(addr -> addr.getAddress().getHostAddress())
            .defaultIfEmpty("unknown");
    }
}

// ── Minimal Spring Security setup so getPrincipal() works: ──────────────
// Dependencies: spring-boot-starter-security, spring-boot-starter-oauth2-resource-server
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)   // stateless API behind a gateway
            .authorizeExchange(ex -> ex
                .pathMatchers("/api/auth/**", "/actuator/health").permitAll()
                .anyExchange().authenticated())
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
            .build();
    }
}

// application.yml:
// spring.security.oauth2.resourceserver.jwt.issuer-uri: https://your-auth-server/realms/demo

Rate Limiting: Route Configuration

Attach the filter to a route and reference the resolver bean with a SpEL expression. replenish-rate is the steady number of requests per second a client may make, burst-capacity is the most tokens a bucket can hold, and requested-tokens is how many tokens each request costs. With replenish-rate 10 and burst-capacity 20, a client that sends 25 requests in one burst gets roughly the first 20 through and the rest get 429. Because tokens keep refilling while the burst is being processed, a few more than 20 may pass. After the burst the client can sustain about 10 requests per second.
yaml
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: order-service
              uri: lb://order-service
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1
                - name: RequestRateLimiter
                  args:
                    redis-rate-limiter:
                      replenish-rate: 10      # tokens added per second
                      burst-capacity: 20      # maximum tokens in the bucket
                      requested-tokens: 1     # tokens consumed per request
                    key-resolver: "#{@userKeyResolver}"

Circuit Breaker: Route and Resilience4j Configuration

The CircuitBreaker filter wraps a route with a Resilience4j circuit breaker. If the downstream service starts failing or timing out, the circuit opens and later requests immediately receive a fallback response. That protects the gateway and gives the failing service time to recover. With the settings below, the circuit looks at the last 10 calls. If at least 50 percent of them failed, it opens and stays open for 10 seconds, then lets 3 trial calls through in the half-open state. The time limiter counts any call slower than 3 seconds as a failure.
yaml
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: payment-service
              uri: lb://payment-service
              predicates:
                - Path=/api/payments/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    name: paymentCircuitBreaker
                    fallbackUri: forward:/fallback/payments   # on open circuit, forward internally

resilience4j:
  circuitbreaker:
    instances:
      paymentCircuitBreaker:
        sliding-window-size: 10
        failure-rate-threshold: 50                 # open if 50% or more of 10 calls fail
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 3
  timelimiter:
    instances:
      paymentCircuitBreaker:
        timeout-duration: 3s                       # a call slower than 3s counts as a failure

Circuit Breaker: Fallback Controller

The fallback controller runs inside the gateway process. Use @RequestMapping rather than @GetMapping: a forward keeps the original HTTP method, so a POST to /api/payments would otherwise hit a GET-only handler and fail with 405 instead of returning your fallback. Log the underlying exception on the server and return a generic message to the client. Returning the raw exception message exposes internal hostnames and error details to the outside world.
Java
@RestController
@RequestMapping("/fallback")
public class FallbackController {

    private static final Logger log = LoggerFactory.getLogger(FallbackController.class);

    @RequestMapping("/payments")   // handles every HTTP method that gets forwarded here
    public ResponseEntity<Map<String, Object>> paymentFallback(ServerWebExchange exchange) {
        Throwable cause = exchange.getAttribute(
            ServerWebExchangeUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR);
        log.warn("Payment fallback triggered", cause);   // details stay on the server

        return ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "status",  "unavailable",
                "message", "Payment service is temporarily unavailable. Please try again shortly."
            ));
    }

    @RequestMapping("/orders")
    public ResponseEntity<Map<String, Object>> orderFallback() {
        return ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "status",  "unavailable",
                "message", "Order service is temporarily unavailable.",
                "orders",  Collections.emptyList()
            ));
    }
}

Global Filter: Request ID

A GlobalFilter applies to every route without being declared per route. It is the right place for cross-cutting concerns such as request IDs, access logging and authentication. Filter order matters, and lower numbers run first. The three filters in this guide run as request ID (-3), logging (-2) and authentication (-1). Logging before authentication means rejected requests, such as 401 responses, still appear in your logs. The request ID filter generates a new ID for every request when the client did not send one, and returns it in the response so a caller can quote it in a support ticket. Doing this in a filter, rather than with a static header in YAML or the Java DSL, is what makes the value unique per request.
Java
@Component
public class RequestIdFilter implements GlobalFilter, Ordered {

    public static final String HEADER = "X-Request-Id";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String incoming = exchange.getRequest().getHeaders().getFirst(HEADER);
        String requestId = (incoming == null || incoming.isBlank() || incoming.length() > 64)
            ? UUID.randomUUID().toString()
            : incoming;

        ServerHttpRequest mutated = exchange.getRequest().mutate()
            .headers(h -> h.set(HEADER, requestId))
            .build();

        exchange.getResponse().getHeaders().set(HEADER, requestId);
        return chain.filter(exchange.mutate().request(mutated).build());
    }

    @Override
    public int getOrder() {
        return -3;
    }
}

Global Filter: Request Logging

This filter logs the method, path, client address and request ID when a request arrives, and the status code and duration when it completes. doFinally runs for success, error and cancellation, so failed calls are logged too. Never log the Authorization header or request bodies; they contain credentials and personal data.
Java
@Component
public class LoggingFilter implements GlobalFilter, Ordered {

    private static final Logger log = LoggerFactory.getLogger(LoggingFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        long start = System.currentTimeMillis();

        log.info("-> {} {} | IP: {} | RequestId: {}",
            request.getMethod(),
            request.getURI().getPath(),
            request.getRemoteAddress(),
            request.getHeaders().getFirst(RequestIdFilter.HEADER));

        return chain.filter(exchange)
            .doFinally(signal -> log.info("<- {} {} | Status: {} | {}ms",
                request.getMethod(),
                request.getURI().getPath(),
                exchange.getResponse().getStatusCode(),
                System.currentTimeMillis() - start));
    }

    @Override
    public int getOrder() {
        return -2;   // after request ID, before authentication
    }
}

Global Filter: JWT Authentication

This filter validates a Bearer token and passes the user's identity to downstream services in headers. It assumes you have a JwtTokenProvider component of your own that verifies the signature and expiry and reads the claims. Two security details are easy to miss. First, a header set to an empty string is still a header: to remove Authorization you must remove it from the header map. Second, always delete X-User-Id and X-User-Role from incoming requests, including on public paths. Otherwise a client can send its own X-User-Role: ADMIN and downstream services that trust the header will believe it. Stripping the Authorization header means downstream services cannot validate the token themselves. That is fine if they only trust traffic from the gateway (enforce this with network rules or mutual TLS). If you want defense in depth, forward the token instead. For production systems, prefer Spring Security's OAuth2 resource server (shown in the rate limiting section) over a hand-written filter. It handles key rotation, clock skew and standard claim validation. A custom filter is still useful when you need to map claims into headers in a specific way.
Java
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

    private static final String USER_ID_HEADER   = "X-User-Id";
    private static final String USER_ROLE_HEADER = "X-User-Role";

    private static final List<String> PUBLIC_PATHS = List.of(
        "/api/auth/login",
        "/api/auth/register",
        "/actuator/health"
    );

    private final JwtTokenProvider jwtProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        // Public endpoints skip authentication, but spoofed identity headers are still removed:
        if (PUBLIC_PATHS.stream().anyMatch(path::startsWith)) {
            ServerHttpRequest clean = request.mutate()
                .headers(h -> {
                    h.remove(USER_ID_HEADER);
                    h.remove(USER_ROLE_HEADER);
                })
                .build();
            return chain.filter(exchange.mutate().request(clean).build());
        }

        String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return unauthorized(exchange);
        }

        String token = authHeader.substring(7);
        if (!jwtProvider.validateToken(token)) {
            return unauthorized(exchange);
        }

        String userId   = jwtProvider.getUserId(token);
        String userRole = jwtProvider.getRole(token);

        ServerHttpRequest mutated = request.mutate()
            .headers(h -> {
                h.remove(HttpHeaders.AUTHORIZATION);   // really removes it (an empty value would not)
                h.remove(USER_ID_HEADER);              // discard anything the client sent
                h.remove(USER_ROLE_HEADER);
                h.set(USER_ID_HEADER, userId);
                h.set(USER_ROLE_HEADER, userRole);
            })
            .build();

        return chain.filter(exchange.mutate().request(mutated).build());
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }

    @Override
    public int getOrder() {
        return -1;   // runs after request ID and logging
    }
}

Testing and Troubleshooting

When a route does not behave as expected, start with the Actuator gateway endpoint. It lists every route the gateway has loaded, in evaluation order, with its predicates, filters and order value. Most problems show up there as a missing route, a wrong order or a typo in a predicate. Turn on debug logging for org.springframework.cloud.gateway to see which route matched and which filters ran. For automated tests, disable Eureka (eureka.client.enabled=false) and use WebTestClient against the running gateway; mock downstream services with WireMock or MockWebServer. Symptoms and likely causes: a 404 on a path you configured usually means route order or a predicate mismatch (including the HTTP method); a 503 from lb:// means the service is not registered in Eureka under that name; a 401 on every call means the auth filter or security chain is rejecting the token; a 429 means the rate limiter bucket is empty for that key.
Java
# List all loaded routes (in evaluation order):
curl http://localhost:8080/actuator/gateway/routes

# Inspect one route:
curl http://localhost:8080/actuator/gateway/routes/user-service

# Test the canary route:
curl -H "X-Canary: true" http://localhost:8080/api/users/1

# application.yml: debug logging for route matching
logging:
  level:
    org.springframework.cloud.gateway: DEBUG
    reactor.netty.http.client: DEBUG

// ── Integration test: ───────────────────────────────────────────────────
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
                properties = "eureka.client.enabled=false")
@AutoConfigureWebTestClient
class GatewayRoutingTest {

    @Autowired
    WebTestClient client;

    @Test
    void unknownPathReturnsNotFound() {
        client.get().uri("/api/unknown").exchange()
            .expectStatus().isNotFound();
    }

    @Test
    void protectedRouteWithoutTokenReturnsUnauthorized() {
        client.get().uri("/api/orders/1").exchange()
            .expectStatus().isUnauthorized();
    }
}

Common Mistakes

1. Adding spring-boot-starter-web. The gateway needs the reactive stack. Both starters on the classpath cause startup failures or the gateway running in the wrong mode. Remove the web starter. 2. Declaring broad routes before specific ones. The first match wins, so a specific canary or header route declared after a general route never runs. Put specific routes first and set order explicitly. 3. Blocking inside a filter. A JDBC call, a blocking REST client or Thread.sleep in a GlobalFilter blocks a small number of Netty event-loop threads and can freeze the whole gateway. Use reactive clients (WebClient, reactive Redis) or move the work elsewhere. 4. Expecting per-request values from static configuration. A header value computed in the Java DSL or a property placeholder is evaluated once at startup. Generate per-request values (IDs, timestamps) in a global filter. 5. Trusting identity headers from clients. If downstream services trust X-User-Id, the gateway must delete that header from every incoming request before setting its own. 6. Using the user rate limiter without Spring Security. getPrincipal() is empty without it, so every client shares the "anonymous" bucket. 7. Retrying non-idempotent methods. Restrict the Retry filter to GET and HEAD. 8. Mixing release trains and Boot versions. Use the Spring Cloud release train that matches your Spring Boot version, and use the renamed artifact and property prefix on release train 2025.0 and later. 9. Leaking internal errors. Do not return exception messages from fallback controllers. Log them instead.

Frequently Asked Questions

What is the difference between Spring Cloud Gateway and Zuul? Zuul 1.x is built on the blocking servlet API, with one thread per request. Spring Cloud Gateway is built on WebFlux and Netty and is non-blocking, so it handles many concurrent connections with few threads and supports WebSockets and streaming. Spring Cloud Gateway is the actively developed project and the recommended choice for new Spring applications. Should I define routes in YAML or with the Java DSL? Use YAML for static routes: it is easy to read and can be changed without recompiling. Use the Java DSL when routes depend on beans, environment logic or loops over a list of services. You can mix both; the same ordering rules apply to all routes. What is the difference between a predicate and a filter? A predicate decides whether a route applies to a request (path, header, method, time, host). A filter changes the request or response once the route has been chosen (rewrite the path, add a header, rate limit, retry). What is the difference between a GlobalFilter and a route filter? A GlobalFilter applies to every route and is written in Java. A route filter is declared on a specific route and is usually configured in YAML. Use global filters for logging, request IDs and authentication; use route filters for per-service behavior such as retries or a rate limit. Does the gateway need Eureka? No. Eureka is only needed for lb:// URIs. You can route to fixed URLs such as http://localhost:8081 or http://user-service:8080 without any service discovery. How do I add authentication at the gateway? The recommended approach is Spring Security's OAuth2 resource server with JWT validation. A custom GlobalFilter works too, and is useful for mapping claims into headers. Either way, make sure downstream services cannot be reached directly from outside your network. Why does my route return 404? The usual causes are route order (a broader route earlier in the list), a predicate that does not match (path pattern or HTTP method), or a service name that does not match the one registered in Eureka. Check /actuator/gateway/routes and enable DEBUG logging for org.springframework.cloud.gateway. How do I add CORS to the gateway? Configure global CORS under spring.cloud.gateway.server.webflux.globalcors in the gateway, and use the DedupeResponseHeader filter so a downstream service's CORS headers are not duplicated.