Rate Limiting in Spring

Bucket4j

Bucket4j is a production-ready token-bucket library for Java with in-memory and distributed (Redis, Hazelcast, JDBC) backends.

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j_jdk17-core</artifactId>
    <version>8.14.0</version>
</dependency>

A Login Rate-Limit Filter

@Component
public class LoginRateLimitFilter extends OncePerRequestFilter {

    // in production use Caffeine with expiry (or Redis) so this map cannot grow forever
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    private Bucket newBucket() {
        return Bucket.builder()
                .addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofMinutes(1)))
                .build();
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !("POST".equals(request.getMethod()) && request.getRequestURI().startsWith("/api/auth/"));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String key = request.getRemoteAddr();      // real client IP: set server.forward-headers-strategy
        ConsumptionProbe probe = buckets.computeIfAbsent(key, k -> newBucket()).tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            response.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
            chain.doFilter(request, response);
            return;
        }
        long waitSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()) + 1;
        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(waitSeconds));
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("{\"status\":429,\"error\":\"Too Many Requests\",\"retryAfterSeconds\":" + waitSeconds + "}");
    }
}

Because it is a @Component filter, Spring Boot registers it as a servlet filter that runs before the Spring Security chain — rejected requests never reach password hashing, which itself costs CPU.

Hitting the limit

for i in $(seq 1 11); do
  curl -s -o /dev/null -w "%{http_code} " -X POST localhost:8080/api/auth/login \
       -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"guess'$i'"}'
done; echo
curl -i -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"email":"x","password":"y"}'

Example output

401 401 401 401 401 401 401 401 401 401 429

HTTP/1.1 429
Retry-After: 6
Content-Type: application/json

{"status":429,"error":"Too Many Requests","retryAfterSeconds":6}

Multiple Instances

An in-memory map only limits one instance. With 4 pods behind a load balancer, an attacker gets 4× the limit. Share buckets in Redis (Bucket4j bucket4j_jdk17-lettuce / -redisson), or rate-limit at the edge: Spring Cloud Gateway RequestRateLimiter (see the Microservices material), Nginx limit_req, or Cloudflare / AWS WAF rules.