Skip to content
Chapter 11 · Microservices

API Gateway: Filters and Authentication ​

Chapter 10 used the gateway for routing and load balancing. Everything else on the list (authentication, rate limiting, retries, circuit breakers, transformations, logging) is done with filters.

A filter intercepts a request and response as they pass through the gateway, and can inspect or modify them. There are two kinds:

  • Global filters apply to every request through the gateway. Good for authentication and logging.
  • Route-specific filters (also called gateway filters) apply only to requests matching one route. Good for rate limiting, retries and circuit breakers, which usually need different settings per service.

The life of a request ​

A request to localhost:8083/products/1 travels like this:

  1. The DispatcherHandler (WebFlux's equivalent of the DispatcherServlet) receives the request.
  2. RoutePredicateHandlerMapping checks each route's predicates and picks the route (/products/** → route 0).
  3. The request passes through a chain of filters. Each filter runs its pre-logic, then calls the next filter.
  4. RouteToRequestUrlFilter combines the route's URI with the request path. For lb:// URIs, ReactiveLoadBalancerClientFilter then asks service discovery for the instances and picks one.
  5. NettyRoutingFilter sends the request to the service.
  6. On the way back, the response passes through the filters in reverse order, each running its post-logic.
  7. NettyWriteResponseFilter writes the response to the client.

Filter order ​

Some global filters run before the route filters and some after. That is because every filter, global or route-specific, has an order value, and the chain is sorted by it:

  • Lower value = higher priority = earlier in the chain (pre-logic first, post-logic last).
  • Most global filters have low values, so they run before route filters.
  • RouteToRequestUrlFilter (10000), ReactiveLoadBalancerClientFilter (10150) and NettyRoutingFilter (Ordered.LOWEST_PRECEDENCE) have very high values on purpose: they must run last, after every other filter has had its say.
  • NettyWriteResponseFilter has order -1, so it runs early. Its pre-logic is empty; all its work is post-logic, and because it is early in the chain, its post-logic runs last, which is exactly when the response should be written.

Defaults:

  • A global filter without an order gets Ordered.LOWEST_PRECEDENCE.
  • Route filters without an order get 1, 2, 3... in the order they are listed for the route.

Step 1: a global authentication filter ​

Authentication applies to every request, so it is a global filter. Implement GlobalFilter, plus Ordered to run it early:

java
@Component
public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String path = exchange.getRequest().getURI().getPath();

        // tokens are issued by an auth service; don't demand one to get one
        if (path.startsWith("/auth")) {
            return chain.filter(exchange);
        }

        String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();          // stop here
        }

        String token = authHeader.substring(7);
        try {
            // verify the JWT: signature, expiry, claims...
        } catch (Exception e) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // pre-logic done: pass the request on, then run post-logic when the response is back
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            // post-logic here
        }));
    }

    @Override
    public int getOrder() {
        return -1;          // early in the chain
    }
}
  • exchange holds the request and the response.
  • chain.filter(exchange) passes the request to the next filter.
  • To reject, set the status and call setComplete() without calling the chain. The request never reaches a service.
  • The gateway is reactive: chain.filter(...) returns at once with a Mono that completes later, when the response is back. Code written after it would run before the downstream call finishes. Post-logic therefore goes inside .then(Mono.fromRunnable(...)), which runs when the Mono completes.

The gateway only verifies tokens. Issuing them is the job of an authentication service, which is why /auth paths pass through. JWT creation and verification themselves are covered in the Spring Boot book's security chapters.

Calls through the gateway:

text
no Authorization header        -> 401 Unauthorized
Authorization: Bearer <bad>    -> 401 Unauthorized
Authorization: Bearer <valid>  -> 200, forwarded to product-service

Every service behind the gateway is now protected without a line of security code in it.

Step 2: built-in route filters ​

Spring Cloud Gateway ships many route filters, including:

FilterDoes
AddRequestHeaderadds a header to the request
AddResponseHeaderadds a header to the response
RemoveRequestHeaderremoves a header from the request
RemoveResponseHeaderremoves a header from the response
Retryretries the request on failure
RequestRateLimiterrate limiting
CircuitBreakercircuit breaker with fallback

Filters are a list under each route, configured by name and args:

properties
spring.cloud.gateway.routes[0].id=product-service
spring.cloud.gateway.routes[0].uri=lb://product-service
spring.cloud.gateway.routes[0].predicates[0]=Path=/products/**

# add a request header
spring.cloud.gateway.routes[0].filters[0].name=AddRequestHeader
spring.cloud.gateway.routes[0].filters[0].args.name=X-Test-RequestHeader
spring.cloud.gateway.routes[0].filters[0].args.value=ApiGatewayRequest

# add a response header
spring.cloud.gateway.routes[0].filters[1].name=AddResponseHeader
spring.cloud.gateway.routes[0].filters[1].args.name=X-Test-ResponseHeader
spring.cloud.gateway.routes[0].filters[1].args.value=ApiGatewayResponse

# strip a header clients must not send on
spring.cloud.gateway.routes[0].filters[2].name=RemoveRequestHeader
spring.cloud.gateway.routes[0].filters[2].args.name=Api-private-Key

A client sends Api-private-Key: secret. product-service receives X-Test-RequestHeader: ApiGatewayRequest and no Api-private-Key; the client's response carries X-Test-ResponseHeader: ApiGatewayResponse.

How the names map to classes ​

The name is a class name minus its suffix: AddRequestHeader is AddRequestHeaderGatewayFilterFactory. Each factory has a config class, and args.<field> sets that class's fields. AddRequestHeaderGatewayFilterFactory uses NameValueConfig, with fields name and value; RemoveRequestHeaderGatewayFilterFactory uses NameConfig, with just name. To learn a filter's arguments, open its factory and read its config class.

Step 3: the Retry filter ​

properties
# retry GET and DELETE requests up to 4 times
spring.cloud.gateway.routes[0].filters[0].name=Retry
spring.cloud.gateway.routes[0].filters[0].args.retries=4
spring.cloud.gateway.routes[0].filters[0].args.methods=GET,DELETE

RetryGatewayFilterFactory uses RetryConfig, with fields such as retries, methods, statuses and series.

  • retries counts retries, not attempts: retries=4 means the original call plus 4 retries, 5 attempts. Against an endpoint that always returns 500, product-service logged five GET attempts.
  • By default it retries 5xx responses (and I/O failures) for GET only; methods widens or narrows that. A POST to the same failing endpoint was tried once: POST is not idempotent (chapter 8).

This filter is the gateway's own implementation, not Resilience4j.

Step 4: the CircuitBreaker filter ​

This one uses Resilience4j through Spring Cloud CircuitBreaker. Add to the gateway:

xml
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>

Configure the filter and the breaker:

properties
spring.cloud.gateway.routes[0].filters[0].name=CircuitBreaker
spring.cloud.gateway.routes[0].filters[0].args.name=myCircuitBreakerName
spring.cloud.gateway.routes[0].filters[0].args.fallbackUri=forward:/fallback

resilience4j.circuitbreaker.instances.myCircuitBreakerName.sliding-window-type=COUNT_BASED
resilience4j.circuitbreaker.instances.myCircuitBreakerName.sliding-window-size=10
resilience4j.circuitbreaker.instances.myCircuitBreakerName.minimum-number-of-calls=5
resilience4j.circuitbreaker.instances.myCircuitBreakerName.failure-rate-threshold=50
resilience4j.circuitbreaker.instances.myCircuitBreakerName.wait-duration-in-open-state=10s
resilience4j.circuitbreaker.instances.myCircuitBreakerName.permitted-number-of-calls-in-half-open-state=3
  • args.name names the breaker; its settings come from the resilience4j.circuitbreaker.instances.<name> properties exactly as in chapter 9.
  • args.fallbackUri=forward:/fallback sends failed calls to an endpoint inside the gateway (forward: means an internal forward, not a call to another service):
java
@RestController
public class FallbackController {

    @RequestMapping("/fallback")
    public ResponseEntity<String> fallback() {
        return ResponseEntity.ok("Service is temporarily unavailable, please try again later.");
    }
}

With product-service stopped, every call returns the fallback message. After the fifth failure the log shows CircuitBreaker 'myCircuitBreakerName' changed state from CLOSED to OPEN, and from then on the gateway does not even try to connect until the wait ends.

RequestRateLimiter works the same way (name plus args); its standard implementation keeps its counters in Redis, so that all gateway instances share them.

Step 5: a custom route filter ​

Extend AbstractGatewayFilterFactory with your own config class. The class name must end in GatewayFilterFactory:

java
@Component
public class CustomRouteGatewayFilterFactory
        extends AbstractGatewayFilterFactory<CustomRouteGatewayFilterFactory.CustomConfig> {

    public CustomRouteGatewayFilterFactory() {
        super(CustomConfig.class);
    }

    @Override
    public GatewayFilter apply(CustomConfig config) {
        return (exchange, chain) -> {
            System.out.println("pre filter logic, country: " + config.getCountry());
            return chain.filter(exchange).then(Mono.fromRunnable(() ->
                    System.out.println("post filter logic")));
        };
    }

    public static class CustomConfig {
        private String country;
        public String getCountry() { return country; }
        public void setCountry(String country) { this.country = country; }
    }
}

Use it by name (CustomRoute), with country as an arg. Give it the next free index in the route's filter list:

properties
spring.cloud.gateway.routes[0].filters[3].name=CustomRoute
spring.cloud.gateway.routes[0].filters[3].args.country=India

With the global JWT filter (order -1) and this route filter both active, one request logs:

text
global pre
route pre, country=India
route post
global post

The global filter's pre-logic runs first and its post-logic last, wrapped around the route filter: the ordering rules above, in action.

Next: The services are reachable, protected and resilient, but each still carries its own application.properties. Chapter 12 moves configuration out of the services into a central config server.

Check yourself ​

  1. What is the difference between a global filter and a route-specific filter? Give an example use of each.
  2. Trace a request through the gateway, from DispatcherHandler to the response.
  3. How is the filter chain ordered, and what does a lower order value mean?
  4. Why do NettyRoutingFilter and RouteToRequestUrlFilter have very high order values?
  5. Why does NettyWriteResponseFilter have order -1?
  6. What order do route filters get by default?
  7. How does a global filter reject a request?
  8. Why must post-logic go in .then(Mono.fromRunnable(...))?
  9. Why does the JWT filter let /auth paths through?
  10. How does filters[0].name=AddRequestHeader with args.name and args.value map to code?
  11. How many attempts does Retry with args.retries=4 make, and is a POST retried by default?
  12. What does fallbackUri=forward:/fallback do?
  13. What must a custom filter class be named, and how do you pass it settings?
Answers
  1. A global filter applies to every request (authentication, logging); a route filter applies only to requests on one route (retry, circuit breaker or rate limiting with per-service settings).
  2. DispatcherHandler → RoutePredicateHandlerMapping picks the route → filters' pre-logic in order → RouteToRequestUrlFilter builds the URL, ReactiveLoadBalancerClientFilter resolves lb:// → NettyRoutingFilter calls the service → filters' post-logic in reverse order → NettyWriteResponseFilter writes the response.
  3. By each filter's order value, global and route filters together; a lower value means higher priority: earlier pre-logic and later post-logic.
  4. They must run after all other filters: they build the final URL and send the request.
  5. So it is early in the chain and its post-logic, which writes the response to the client, runs last.
  6. 1, 2, 3... in the order they are listed for the route.
  7. Set the response status (e.g. 401) and return exchange.getResponse().setComplete() without calling the chain.
  8. The gateway is reactive: chain.filter returns immediately and completes later; code after it would run before the response arrives.
  9. Tokens are issued there; requiring a token to get a token would lock everyone out.
  10. The name selects AddRequestHeaderGatewayFilterFactory; args.name and args.value set the fields of its config class (NameValueConfig).
  11. Five (the call plus 4 retries); no, only GET by default.
  12. When the call fails or the circuit is open, the gateway forwards the request internally to its own /fallback endpoint.
  13. <Name>GatewayFilterFactory, extending AbstractGatewayFilterFactory with a config class; settings go in filters[i].args.<field>, used as name=<Name>.