java

Build a Secure Spring Cloud Gateway with JWT, Rate Limiting, and Circuit Breakers

Learn to build a secure Spring Cloud Gateway with JWT validation, Redis rate limiting, and circuit breakers to protect microservices.

Build a Secure Spring Cloud Gateway with JWT, Rate Limiting, and Circuit Breakers

I still remember the day our production gateway collapsed under a sudden spike from a misbehaving client. We had no rate limiting, no circuit breakers, and every bad request went straight to our fragile microservices. That night I decided I would never let that happen again. So let me walk you through building a gateway that doesn’t just route requests but actually protects your backend, validates tokens before they reach your services, and throttles abusive clients—all with Spring Cloud Gateway.

Why a Gateway? Why Not Just Nginx?

You can slap an Nginx reverse proxy in front of your services and call it a day. But when you need to inject user identity headers based on a JWT, apply per-user rate limits stored in Redis, or react to failures with circuit breakers, Nginx configuration quickly becomes a mess of Lua scripts and fragile custom modules. Spring Cloud Gateway, on the other hand, lets you express these cross-cutting concerns as Java filters—testable, debuggable, and composable.

Have you ever tried to validate a JWT inside an Nginx auth_request and then propagate the claims downstream? It’s possible, but it’s painful. With Spring Cloud Gateway, you write a GatewayFilter that parses the token, extracts the sub and roles, and stuffs them into headers before the request ever touches your services.

Let me show you what I mean.

The Project Setup

I created a new project using Spring Initializr with these dependencies:

  • Gateway (Reactive)
  • Security
  • Data Redis Reactive
  • Actuator
  • Circuit Breaker (Resilience4j)
  • Lombok (optional, but helps with boilerplate)

My pom.xml includes the parent spring-boot-starter-parent:3.3.4 and the Spring Cloud BOM 2023.0.3. I added JJWT 0.12.6 for token parsing. Here’s the core dependency block:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Defining Routes – Java DSL vs YAML

I prefer defining routes in Java because it gives me full control and type safety. In a @Configuration class, I inject a RouteLocatorBuilder and define routes like this:

@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("user-service", r -> r
            .path("/api/users/**")
            .filters(f -> f
                .filter(jwtAuthenticationFilter)
                .requestRateLimiter(config -> config
                    .setRateLimiter(redisRateLimiter)
                    .setKeyResolver(userKeyResolver))
                .circuitBreaker(config -> config
                    .setName("userServiceCB")
                    .setFallbackUri("forward:/fallback/users")))
            .uri("lb://user-service"))
        .build();
}

Notice how I chain jwtAuthenticationFilter (global) with a rate limiter and a circuit breaker. The order matters: first validate the token, then rate‑limit (so you don’t waste resources parsing a token that belongs to an already throttled user), then forward. If the downstream service fails, the circuit breaker kicks in and routes to a fallback.

JWT Validation: The Heart of Security

I wanted every request except the public login endpoint to carry a valid JWT. So I created a GlobalFilter that intercepts all requests, checks the Authorization header, parses the token using a secret key, and extracts claims.

@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

    private final JwtTokenValidator validator;

    public JwtAuthenticationFilter(JwtTokenValidator validator) {
        this.validator = validator;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();
        if (path.startsWith("/api/public/")) {
            return chain.filter(exchange);  // skip JWT for public endpoints
        }

        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 {
            Claims claims = validator.validate(token);
            // enrich the request with user info
            exchange.getAttributes().put("userId", claims.getSubject());
            exchange.getAttributes().put("roles", claims.get("roles"));
            // also add headers for downstream services
            ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
                .header("X-User-Id", claims.getSubject())
                .header("X-User-Roles", String.join(",", claims.get("roles", List.class)))
                .build();
            ServerWebExchange modifiedExchange = exchange.mutate().request(modifiedRequest).build();
            return chain.filter(modifiedExchange);
        } catch (JwtException e) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
    }

    @Override
    public int getOrder() {
        return -100; // high priority
    }
}

The JwtTokenValidator class uses JJWT to parse:

@Component
public class JwtTokenValidator {
    private final SecretKey key;

    public JwtTokenValidator(@Value("${jwt.secret}") String secret) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public Claims validate(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

Now every request carries the user’s identity downstream without forcing each microservice to parse JWT again. That saves time and keeps your services stateless.

Rate Limiting – Not Just a Generic Throttle

Spring Cloud Gateway ships with a RequestRateLimiter filter that uses Redis as a token bucket. But the default key resolver uses the caller’s IP. I often want per-user rate limiting instead. So I implemented a custom KeyResolver that pulls the user ID from the exchange attributes (which we set during JWT validation).

@Component
public class UserKeyResolver implements KeyResolver {
    @Override
    public Mono<String> resolve(ServerWebExchange exchange) {
        String userId = exchange.getAttribute("userId");
        if (userId == null) {
            return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
        }
        return Mono.just(userId);
    }
}

I also need a RedisRateLimiter bean:

@Bean
public RedisRateLimiter redisRateLimiter() {
    return new RedisRateLimiter(10, 20, 1); // replenishRate, burstCapacity, requestedTokens
}

The first argument replenishRate is how many requests per second the bucket refills. burstCapacity is the maximum number of requests allowed in a sudden burst. I often expose these as configuration properties per route.

Circuit Breakers – Graceful Degradation

When a downstream service is slow or failing, you don’t want your gateway to hang and exhaust connections. I integrated Resilience4j’s ReactiveCircuitBreakerFactory directly into the route filter chain. The circuit breaker opens after a configurable number of failures, and you can define a fallback URI.

.route("orders", r -> r
    .path("/api/orders/**")
    .filters(f -> f
        .filter(jwtAuthenticationFilter)
        .circuitBreaker(config -> config
            .setName("ordersCB")
            .setStatusCodes(HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.SERVICE_UNAVAILABLE.value())
            .setFallbackUri("forward:/fallback/orders"))
        .requestRateLimiter(...))
    .uri("lb://order-service"))

The fallback endpoint is a simple controller that returns a friendly message: “We’re experiencing issues. Please try again later.”

Testing the Gateway

I don’t trust a gateway unless I test it with real HTTP calls. Spring Cloud Gateway integrates with WebTestClient because it’s reactive. I start a mock downstream service using WireMock, then write tests like:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class GatewayRoutingIntegrationTest {

    @Autowired
    private WebTestClient webClient;

    @Test
    void shouldRouteToUserServiceWithValidToken() {
        String token = generateValidJwt("user123", List.of("ROLE_USER"));

        webClient.get()
            .uri("/api/users/me")
            .header("Authorization", "Bearer " + token)
            .exchange()
            .expectStatus().isOk()
            .expectBody(String.class).isEqualTo("Hello from user service");
    }

    @Test
    void shouldRejectRequestWithoutToken() {
        webClient.get()
            .uri("/api/users/me")
            .exchange()
            .expectStatus().isUnauthorized();
    }

    @Test
    void shouldRateLimitAfterExceedingBurst() {
        // send 25 requests rapidly, expect 429 after 20 (burst capacity)
        String token = generateValidJwt("user123", List.of("ROLE_USER"));
        for (int i = 0; i < 25; i++) {
            webClient.get()
                .uri("/api/users/me")
                .header("Authorization", "Bearer " + token)
                .exchange()
                .expectStatus().isOneOf(HttpStatus.OK, HttpStatus.TOO_MANY_REQUESTS);
        }
    }
}

Notice I don’t check each response individually; I just confirm that at some point we get a 429. This makes the test practical and not brittle.

What About Observability?

I added a LoggingFilter that captures the request method, path, status code, and time taken, then logs it as a structured JSON line. I also exposed Micrometer metrics via Actuator. The gateway automatically records metrics like gateway.requests. I bind them to Prometheus and build a dashboard. When latency spikes, I can drill down to the route level.

Have you ever tried to debug a slow API call and didn’t know where the delay happened? With structured logging and metrics from the gateway, you see the exact time the gateway spent parsing the JWT, waiting on Redis for rate limiting, and calling the downstream service. That visibility alone is worth the effort.

Production Tuning Considerations

Spring Cloud Gateway runs on Reactor Netty. You may need to increase the number of connection pools, set timeouts, and tune the event loop. In application.yml:

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 2000
        response-timeout: 5s
        pool:
          type: elastic
          max-connections: 500
          acquire-timeout: 3000

I also configure a global timeout for routes using the SpringCloudGatewayProperties. If a downstream service doesn’t respond within 5 seconds, the gateway returns 504 instead of hanging.

Putting It All Together

By now you should have a clear picture: an API gateway that authenticates every request, enforces per-user rate limits to protect your backend, adds circuit breakers for resilience, and logs everything with full context. You can extend it further with validation of request bodies (by adding a filter that checks JSON schema), request/response transformation, or even A/B testing using the Weight route predicate.

My own production gateway now handles millions of requests a day. It’s the single policy enforcement point that gives my team confidence to deploy quickly. When something goes wrong, I can pinpoint the issue within seconds.

If you found this guide helpful, hit the like button, share it with your team, and leave a comment below with your own gateway war stories or questions. I read every one and I’d love to hear how you’re using these patterns.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Spring Cloud Gateway, JWT authentication, rate limiting, circuit breaker, microservices security



Similar Posts
Blog Image
Apache Kafka Spring Cloud Stream Integration Guide: Build Scalable Event-Driven Microservices Architecture

Learn how to integrate Apache Kafka with Spring Cloud Stream for scalable event-driven microservices. Simplify messaging, boost performance, and build resilient systems.

Blog Image
Why You Should Stop Writing JPQL Strings and Start Using QueryDSL

Discover how QueryDSL with Spring Data JPA enables type-safe, maintainable, and error-proof database queries in Java applications.

Blog Image
Apache Kafka Spring WebFlux Integration: Build Scalable Reactive Event Streaming Applications in 2024

Learn to integrate Apache Kafka with Spring WebFlux for reactive event streaming. Build scalable, non-blocking applications that handle real-time data efficiently.

Blog Image
Apache Kafka Spring Cloud Stream Tutorial: Build Reactive Event-Driven Microservices with Complete Implementation

Master Apache Kafka & Spring Cloud Stream for reactive event streaming. Learn producers, consumers, error handling, performance optimization & testing strategies.

Blog Image
Apache Kafka Spring Cloud Stream Integration: Build Scalable Event-Driven Microservices Architecture Guide

Learn how to integrate Apache Kafka with Spring Cloud Stream for scalable event-driven microservices. Build resilient systems with simplified messaging.

Blog Image
Apache Kafka Spring Cloud Stream Integration: Building Scalable Event-Driven Microservices Architecture Guide

Learn how to integrate Apache Kafka with Spring Cloud Stream to build scalable event-driven microservices. Simplify messaging, boost performance, and streamline development workflows.