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