Home Java Write Custom Spring Cloud Gateway Filters: Global, Rate Limiting & Security
Advanced 3 min · July 14, 2026

Write Custom Spring Cloud Gateway Filters: Global, Rate Limiting & Security

Learn to build custom Spring Cloud Gateway filters for global concerns, rate limiting, and security.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Java 17+
  • Spring Boot 3.x and Spring Cloud 2023.x
  • Basic understanding of reactive programming with Project Reactor
  • Familiarity with Spring Cloud Gateway concepts
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Custom Gateway filters allow you to intercept and modify requests/responses at the gateway level.
  • You can create global filters (apply to all routes) or conditional filters (apply to specific routes).
  • Rate limiting filters protect backend services from abuse; use Redis-backed implementations for distributed environments.
  • Security filters (e.g., JWT validation, IP whitelisting) should be implemented as filters to centralize enforcement.
  • Always consider filter ordering and the impact on performance and debugging.
✦ Definition~90s read
What is Writing Custom Spring Cloud Gateway Filters?

A custom Spring Cloud Gateway filter is a piece of code that intercepts and modifies HTTP requests or responses as they pass through the API gateway, enabling you to implement cross-cutting concerns like authentication, rate limiting, and logging.

Think of Spring Cloud Gateway as a security checkpoint at the entrance of a building.
Plain-English First

Think of Spring Cloud Gateway as a security checkpoint at the entrance of a building. Custom filters are like additional checks – one might check if you have a badge (security), another might limit how many people enter per minute (rate limiting), and a global filter might log every entry. You can decide which checks apply to all visitors or only specific ones.

In a microservices architecture, the API gateway is the single entry point for all client requests. Spring Cloud Gateway is a powerful, non-blocking gateway built on Spring WebFlux. While it provides many built-in filters (like AddRequestHeader, CircuitBreaker), real-world systems demand custom behavior. You need to enforce security policies, apply rate limiting, and handle cross-cutting concerns like logging or tracing. Writing custom filters is the way to do that.

In this article, I'll show you how to write three types of custom filters: global filters that apply to every request, rate-limiting filters to protect your backends, and security filters for JWT validation and IP whitelisting. I'll also share a production incident where a poorly written filter caused a full outage, and a debugging guide that will save you hours of frustration. If you're running Spring Cloud Gateway in production, this is the article you need.

Understanding Gateway Filter Types

Spring Cloud Gateway offers two types of filters: global filters and gateway filters. Global filters apply to all routes automatically. Gateway filters are scoped to specific routes via configuration. Both implement the GatewayFilter interface, but global filters also extend GlobalFilter. The key difference is that global filters are always applied, while gateway filters are only applied when the route matches.

When writing custom filters, you must decide which type fits your use case. For cross-cutting concerns like logging, authentication, or rate limiting, global filters are usually appropriate. For route-specific transformations (e.g., adding a header only to /api/v1/orders), use gateway filters.

LoggingGlobalFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component
public class LoggingGlobalFilter implements GlobalFilter, Ordered {

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

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        log.info("Incoming request: {} {}", exchange.getRequest().getMethod(), exchange.getRequest().getURI());
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            log.info("Response status: {}", exchange.getResponse().getStatusCode());
        }));
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE; // Run last
    }
}
Output
Incoming request: GET http://localhost:8080/api/orders
Response status: 200 OK
💡Order Matters
📊 Production Insight
I've seen teams accidentally use global filters for everything, causing performance issues. A global filter that does heavy processing (e.g., JWT validation) on every request – including health check endpoints – can bring down your gateway. Use route predicates to skip filters for certain paths.
🎯 Key Takeaway
Global filters apply to all routes; gateway filters apply only to specific routes. Choose based on scope.

Building a Custom Rate Limiting Filter

Rate limiting is critical to protect your backend services from abuse. Spring Cloud Gateway provides a RequestRateLimiter filter backed by Redis, but you may need custom logic – for example, limiting based on user tier or a combination of endpoint and user.

To build a custom rate limiter, you'll use the RateLimiter interface or implement a filter using a token bucket algorithm. Here's a simple in-memory rate limiter using a custom filter (for demonstration; use Redis in production):

CustomRateLimiterFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Component
public class CustomRateLimiterFilter extends AbstractGatewayFilterFactory<CustomRateLimiterFilter.Config> {

    public CustomRateLimiterFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            String key = exchange.getRequest().getURI().getPath();
            AtomicInteger counter = config.cache.computeIfAbsent(key, k -> new AtomicInteger(0));
            if (counter.incrementAndGet() > config.maxRequests) {
                exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
                return exchange.getResponse().setComplete();
            }
            return chain.filter(exchange).then(Mono.fromRunnable(counter::decrementAndGet));
        };
    }

    public static class Config {
        private int maxRequests = 10;
        private Map<String, AtomicInteger> cache = new ConcurrentHashMap<>();
        public int getMaxRequests() { return maxRequests; }
        public void setMaxRequests(int maxRequests) { this.maxRequests = maxRequests; }
    }
}
Output
Response 429 Too Many Requests when limit exceeded.
⚠ In-Memory Rate Limiter Not for Production
📊 Production Insight
When using Redis-backed rate limiting, monitor your Redis connection pool. I've seen a production incident where the gateway's Redis connection pool exhausted under high load, causing all rate-limited requests to fail with 500 instead of 429.
🎯 Key Takeaway
Always use a distributed store like Redis for rate limiting in clustered environments.

Implementing a Security Filter for JWT Validation

Centralized authentication at the gateway is a common pattern. You can validate JWT tokens in a custom filter before forwarding requests to downstream services. This reduces duplication and ensures consistent enforcement.

Here's a global filter that validates a JWT from the Authorization header using a public key retrieved from a JWK Set URI:

JwtValidationGlobalFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.net.URL;

@Component
public class JwtValidationGlobalFilter implements GlobalFilter, Ordered {

    private final RSAKey rsaKey;

    public JwtValidationGlobalFilter() throws Exception {
        // In production, cache and refresh this regularly
        JWKSet jwkSet = JWKSet.load(new URL("https://auth.example.com/.well-known/jwks.json"));
        this.rsaKey = (RSAKey) jwkSet.getKeys().get(0);
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        String token = authHeader.substring(7);
        try {
            SignedJWT signedJWT = SignedJWT.parse(token);
            JWSVerifier verifier = new RSASSAVerifier(rsaKey);
            if (!signedJWT.verify(verifier)) {
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            }
            // Optionally pass claims to downstream via headers
            exchange.getRequest().mutate().header("X-User-Id", signedJWT.getJWTClaimsSet().getSubject());
        } catch (Exception e) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 10; // Run early
    }
}
Output
Unauthorized 401 if token missing/invalid, else request forwarded with X-User-Id header.
🔥JWK Caching
📊 Production Insight
A common mistake is not handling token expiration gracefully. The exp claim should be checked, and a 401 returned with a WWW-Authenticate header. Also, beware of large tokens that exceed header size limits; you may need to configure max-header-size in the gateway.
🎯 Key Takeaway
Centralize JWT validation at the gateway to avoid duplicating auth logic in each microservice.

Combining Filters: Rate Limiting with Security Context

In many systems, you need to rate-limit per authenticated user, not per IP. This requires combining the security filter with the rate limiter. The security filter extracts the user ID from the JWT and stores it in the exchange's attributes. The rate limiter then uses that attribute to build the key.

SecurityAndRateLimitIntegration.javaJAVA
1
2
3
4
5
6
7
8
// In JwtValidationGlobalFilter, after successful validation:
exchange.getAttributes().put("userId", signedJWT.getJWTClaimsSet().getSubject());

// In custom rate limiter filter:
String userId = exchange.getAttribute("userId");
String key = (userId != null) ? userId : exchange.getRequest().getRemoteAddress().getHostString();
// Use key for rate limiting
Output
Rate limiting key is now based on userId if authenticated, else IP.
💡Filter Ordering Is Critical
📊 Production Insight
Be careful not to mutate the request or response after it has been committed. Once chain.filter(exchange) returns, the response may already be written. If you need to modify the response, use ServerWebExchangeDecorator.
🎯 Key Takeaway
Exchange attributes allow filters to share data. Use them to pass authentication context to downstream filters.

What the Official Docs Won't Tell You

The official Spring Cloud Gateway documentation is decent, but it glosses over several hard-earned lessons.

1. Filter Ordering Is a Minefield Global filters have an implicit order based on Ordered or @Order. But if you mix global and gateway filters, the ordering becomes tricky. Gateway filters run inside the route's filter chain, which is processed after global filters? Actually, the order is: global filters run in order, then for each route, the gateway filters run in the order defined in the route configuration. But if you have a global filter that modifies the request, it might affect how route predicates match. I've seen bugs where a global filter adds a header that changes the route matching.

2. Blocking Code Will Kill Your Performance Spring Cloud Gateway is built on WebFlux, which means it's non-blocking. If you call a blocking API (e.g., Thread.sleep(), JDBC, RestTemplate) inside a filter, you'll block the event loop and degrade performance. Always use reactive clients (WebClient) and avoid blocking operations. I once debugged a gateway that was using a synchronous HTTP call to validate tokens – it caused cascading timeouts.

3. Error Handling Is Tricky If an exception is thrown in a filter, it propagates up the chain. But if you don't have a global error handler, the gateway might return a generic 500 with a stack trace. Use @ExceptionHandler in a @ControllerAdvice or implement a custom ErrorWebExceptionHandler. Also, note that some exceptions (like ResponseStatusException) are handled differently.

4. Testing Filters Is Harder Than It Looks You can't easily unit test a filter without mocking the entire exchange. Use MockServerWebExchange from the test module. For integration tests, use @SpringBootTest with a WebTestClient. But beware: the gateway's route configuration is loaded from properties, so you need to set up the application context correctly.

5. Rate Limiting with Redis: Beware of Timeouts If Redis is down or slow, the rate limiter will throw an exception, potentially causing a 500 error. You should wrap the Redis call in a timeout and fallback to allowing the request (or denying with a 503). The built-in RequestRateLimiter has a fail-fast property; set it to false to allow requests when Redis is unavailable.

ErrorHandlingExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Custom error handler for gateway
@Component
public class GatewayErrorWebExceptionHandler implements ErrorWebExceptionHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        if (ex instanceof ResponseStatusException) {
            exchange.getResponse().setStatusCode(((ResponseStatusException) ex).getStatus());
        } else {
            exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        }
        return exchange.getResponse().setComplete();
    }
}
Output
Custom error response instead of default HTML error.
⚠ Don't Block the Event Loop
📊 Production Insight
I've seen a team spend two days debugging a filter that silently swallowed exceptions because they used .subscribe() instead of returning the Mono. Always return the Mono from the filter chain.
🎯 Key Takeaway
The official docs don't cover ordering pitfalls, blocking code dangers, or Redis failure scenarios. Learn from production experience.

Testing Custom Filters

Testing is essential, but tricky due to the reactive nature. For unit tests, you can use MockServerWebExchange and verify the response. For integration tests, spin up a gateway with a mock backend.

GatewayFilterTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class GatewayFilterTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void testLoggingFilter() {
        webTestClient.get().uri("/api/test")
                .exchange()
                .expectStatus().isOk();
        // Verify log output (e.g., using @OutputCapture)
    }
}
Output
Test passes if filter logs correctly.
🔥Use WireMock for Backend Stubs
📊 Production Insight
Don't forget to test the failure path: what happens when Redis is down, or the JWK endpoint is unreachable? Simulate these conditions in tests.
🎯 Key Takeaway
Integration tests with WebTestClient and WireMock are the most reliable way to test custom filters.
● Production incidentPOST-MORTEMseverity: high

The Rate Limiter That Took Down Our Checkout Service

Symptom
Users saw 429 Too Many Requests errors during checkout, even though they had made only 1 request in the last hour.
Assumption
The developer assumed the rate limiter was per-user (based on JWT subject) but the default key resolver was using the client IP, which was shared by many users behind a NAT.
Root cause
The KeyResolver in the rate limiter filter extracted the client IP from X-Forwarded-For header, but the load balancer was not trusted, so the header was spoofable and often the same for all users behind a corporate proxy.
Fix
Changed the key resolver to use a combination of user ID (from JWT) and API endpoint. Added trusted proxy configuration to correctly extract client IP only when behind a known proxy.
Key lesson
  • Always validate the key resolver logic with production traffic patterns.
  • Use authenticated user IDs for rate limiting when possible, not IP addresses.
  • Test rate limiting under load with realistic traffic profiles.
  • Implement a fallback mechanism (e.g., allow a few extra requests) to avoid false positives.
  • Monitor rate limit hits and alert on anomalies.
Production debug guideSymptom to Action4 entries
Symptom · 01
Filter not executing for certain routes
Fix
Check that the filter is applied to the correct route definition. Verify the filter order; if using @Order, ensure it's not overridden. Enable DEBUG logging for org.springframework.cloud.gateway.
Symptom · 02
Rate limiter returning 429 for legitimate requests
Fix
Inspect the KeyResolver logic. Log the key being used. Verify Redis (if used) is not overloaded or misconfigured. Check for clock skew between gateway and Redis.
Symptom · 03
Security filter rejecting valid tokens
Fix
Check token expiration and signature. Ensure the token is being parsed correctly. Look for whitespace or encoding issues in the Authorization header. Validate the JWK set is up-to-date.
Symptom · 04
Global filter causing high latency
Fix
Profile the filter using a tool like Micrometer. Check for blocking calls (e.g., Thread.sleep, blocking I/O) in the filter chain. Ensure you're using reactive types (Mono, Flux) throughout.
★ Quick Debug Cheat SheetCommon symptoms and immediate actions for custom filter issues
Filter not executing
Immediate action
Check route definition and filter order
Commands
curl -v http://localhost:8080/actuator/gateway/routes
grep 'Filter' application.log | tail -20
Fix now
Add @Order(Ordered.HIGHEST_PRECEDENCE) to global filter and verify route predicates.
Rate limiter 429 unexpectedly+
Immediate action
Log the key being used
Commands
curl -v http://localhost:8080/actuator/metrics/rate.limiter.requests
redis-cli keys 'rate_limiter:*' | head
Fix now
Change KeyResolver to use user ID instead of IP. Add fallback to allow burst.
JWT validation fails+
Immediate action
Check token format and JWK endpoint
Commands
curl -v http://localhost:8080/actuator/gateway/globalfilters
jwt-cli decode <token>
Fix now
Ensure Authorization header is 'Bearer <token>'. Verify JWK set URL is reachable from gateway.
FeatureGlobalFilterGatewayFilter
ScopeAll routesSpecific routes
ConfigurationAutomaticExplicit in route definition
Use caseLogging, security, rate limitingHeader modification, path rewriting
OrderingGlobal order via @OrderOrder per route config
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
LoggingGlobalFilter.java@ComponentUnderstanding Gateway Filter Types
CustomRateLimiterFilter.java@ComponentBuilding a Custom Rate Limiting Filter
JwtValidationGlobalFilter.java@ComponentImplementing a Security Filter for JWT Validation
SecurityAndRateLimitIntegration.javaexchange.getAttributes().put("userId", signedJWT.getJWTClaimsSet().getSubject())...Combining Filters
ErrorHandlingExample.java@ComponentWhat the Official Docs Won't Tell You
GatewayFilterTest.java@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)Testing Custom Filters

Key takeaways

1
Custom filters allow you to centralize cross-cutting concerns like security, rate limiting, and logging at the gateway.
2
Always use reactive, non-blocking code in filters to maintain performance.
3
Use Redis-backed rate limiting in production for distributed consistency.
4
Test filters thoroughly with integration tests using WebTestClient and WireMock.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How would you implement a custom rate limiter using Redis in Spring Clou...
Q02SENIOR
Explain filter ordering in Spring Cloud Gateway. How do you ensure a sec...
Q03JUNIOR
What are the pitfalls of using in-memory rate limiting in a distributed ...
Q01 of 03SENIOR

How would you implement a custom rate limiter using Redis in Spring Cloud Gateway?

ANSWER
Use ReactiveRedisTemplate and implement a token bucket algorithm. The filter would use a KeyResolver to get the key (e.g., user ID), then call redisTemplate.opsForValue().increment() and check the count against a limit. Use expire to reset the window. For production, consider using the built-in RequestRateLimiter filter with a custom KeyResolver.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What is the difference between GlobalFilter and GatewayFilter?
02
How do I pass data from one filter to another?
03
Can I use synchronous code in a custom filter?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Cloud. Mark it forged?

3 min read · try the examples if you haven't

Previous
Introduction to Spring Cloud Vault: Secure Secrets Management for Microservices
16 / 34 · Spring Cloud
Next
Guide to Spring Cloud Kubernetes: Service Discovery, ConfigMaps, and Scaling