Rate limiting en Spring Security con Bucket4j y Resilience4j

Avanzado
Spring Security
Spring Security
Actualizado: 19/04/2026

El rate limiting es una capa de defensa imprescindible en cualquier API expuesta a internet. Sin él, un cliente abusivo puede saturar la base de datos, agotar el cupo de un proveedor externo o reventar la tarifa de un servicio de mensajería en cuestión de minutos.

En Spring Security 7.x sobre Spring Boot 3.3+ existen dos bibliotecas dominantes para esta tarea: Bucket4j y Resilience4j. Cada una resuelve el problema desde un ángulo diferente y conviven sin conflicto en el mismo proyecto.

Por qué limitar peticiones a nivel de seguridad

El rate limiting resuelve tres problemas clásicos:

  • 1. Mitigación de fuerza bruta: limitar POST /auth/login a cinco intentos por minuto frena ataques de diccionario sin necesidad de bloquear cuentas legítimas.
  • 2. Protección de recursos costosos: endpoints que consultan APIs externas de pago, generan PDFs o envían SMS deben tener un cupo por usuario y otro global.
  • 3. Defensa frente a denegación de servicio: incluso un cliente legítimo puede saturar un microservicio con un bucle mal escrito; un bucket compartido evita que tumbe el sistema.

El rate limiting es complementario a la autenticación. Aplícalo antes de validar credenciales en endpoints públicos y después en endpoints autenticados (donde el identificador es el username o el userId).

El algoritmo token bucket

Bucket4j implementa la variante más usada en producción: el token bucket. La idea es sencilla.

  • Cada cliente dispone de un bucket con una capacidad máxima N.
  • Cada cierto intervalo, se añaden tokens hasta el máximo (refill rate).
  • Cada petición consume uno o varios tokens.
  • Si no hay tokens suficientes, la petición se rechaza con HTTP 429 Too Many Requests.
flowchart LR
    A[Cliente] --> B{Tokens disponibles?}
    B -->|Si| C[Consumir token]
    B -->|No| D[HTTP 429 Too Many Requests]
    C --> E[Procesar peticion]
    F[Refill periodico] --> B

El algoritmo permite ráfagas controladas. Un cliente que ha estado inactivo acumula tokens hasta el máximo y puede gastar varios seguidos sin esperar.

Bucket4j sobre Spring Security: filtro HTTP

La integración más natural con Spring Security consiste en un OncePerRequestFilter insertado antes del UsernamePasswordAuthenticationFilter. Empieza añadiendo la dependencia.

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

El filtro identifica al cliente por IP, API key o usuario autenticado y comprueba si tiene tokens disponibles.

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;

import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final ConcurrentMap<String, Bucket> cache = new ConcurrentHashMap<>();

    private Bucket newBucket() {
        Bandwidth limit = Bandwidth.classic(60, Refill.greedy(60, Duration.ofMinutes(1)));
        return Bucket.builder().addLimit(limit).build();
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws java.io.IOException, jakarta.servlet.ServletException {
        String key = req.getRemoteAddr();
        Bucket bucket = cache.computeIfAbsent(key, k -> newBucket());
        if (bucket.tryConsume(1)) {
            chain.doFilter(req, res);
        } else {
            res.setStatus(429);
            res.setHeader("Retry-After", "60");
            res.getWriter().write("Too Many Requests");
        }
    }
}

Y en la configuración de seguridad lo insertamos antes del filtro de autenticación.

@Bean
SecurityFilterChain api(HttpSecurity http, RateLimitFilter rateLimit) throws Exception {
    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .addFilterBefore(rateLimit, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

Identificar al cliente por IP funciona en la primera versión, pero conviene migrar a API key o subject del JWT en cuanto haya autenticación, porque varios usuarios detrás de un mismo NAT comparten IP.

Cabeceras estándar y respuestas 429

La especificación RFC 6585 y el borrador draft-ietf-httpapi-ratelimit-headers definen las cabeceras que un cliente bien programado espera. Un 429 sin información obliga al cliente a adivinar el tiempo de espera, lo que multiplica los reintentos.

  • Retry-After: segundos hasta que el cliente puede volver a intentarlo.
  • X-RateLimit-Limit: cupo máximo por ventana.
  • X-RateLimit-Remaining: tokens restantes.
  • X-RateLimit-Reset: timestamp Unix del próximo refill.
long remaining = bucket.getAvailableTokens();
res.setHeader("X-RateLimit-Limit", "60");
res.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));

Devuelve siempre las cabeceras, incluso en respuestas exitosas. Los SDK modernos (axios-rate-limit, tenacity en Python) las leen para autorregularse y reducir los 429.

Almacenamiento distribuido con Redis

Un ConcurrentHashMap solo funciona en un único nodo. En cuanto despliegues dos instancias del microservicio detrás de un balanceador, el cupo se duplica. La solución estándar es delegar el estado del bucket a Redis, donde Bucket4j ofrece una integración directa.

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.10.1</version>
</dependency>

Configuramos un LettuceBasedProxyManager y reusamos el mismo cliente Lettuce que ya gestiona Spring Data Redis.

import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.StringCodec;

@Bean
LettuceBasedProxyManager<String> proxyManager(RedisClient redisClient) {
    var conn = redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
    return LettuceBasedProxyManager
        .builderFor(conn)
        .withExpirationStrategy(io.github.bucket4j.distributed.ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
        .build();
}

El filtro pasa a obtener el bucket por clave en lugar de mantenerlo en memoria.

String key = "rl:" + req.getRemoteAddr();
Bucket bucket = proxyManager
    .builder()
    .build(key, () -> BucketConfiguration.builder()
        .addLimit(Bandwidth.classic(60, Refill.greedy(60, Duration.ofMinutes(1))))
        .build());

Con esto, todas las réplicas del microservicio comparten el mismo estado y el rate limiting se aplica de forma uniforme aunque haya diez nodos.

Resilience4j RateLimiter como decorador

Resilience4j ofrece la misma capacidad pero a nivel de método en lugar de filtro HTTP. Es la opción adecuada cuando el límite se aplica a una llamada interna a un servicio costoso, no al endpoint público.

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
resilience4j:
  ratelimiter:
    instances:
      pagosExternos:
        limit-for-period: 5
        limit-refresh-period: 1s
        timeout-duration: 0

Y la anotación sobre el método.

@Service
public class PagosService {

    @RateLimiter(name = "pagosExternos", fallbackMethod = "pagoFallback")
    public PagoResponse cobrar(PagoRequest req) {
        return clienteExterno.cobrar(req);
    }

    public PagoResponse pagoFallback(PagoRequest req, RequestNotPermitted ex) {
        throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Cupo de pagos agotado");
    }
}

Bucket4j vs Resilience4j: el primero protege endpoints HTTP frente a clientes externos. El segundo protege llamadas internas a recursos costosos (APIs de terceros, colas de mensajería). Coexisten perfectamente.

Estrategias por tipo de endpoint

No todos los endpoints necesitan el mismo límite. Una buena política diferencia tres categorías.

  • 1. Endpoints de autenticación: cinco intentos por minuto y por IP en POST /auth/login. Más estricto en POST /auth/forgot-password.
  • 2. Endpoints autenticados de lectura: cien o doscientas peticiones por minuto y por usuario.
  • 3. Endpoints autenticados de escritura: treinta o sesenta peticiones por minuto y por usuario, según el dominio.

El siguiente fragmento muestra cómo definir distintos buckets según la ruta.

private Bandwidth resolver(HttpServletRequest req) {
    if (req.getRequestURI().startsWith("/auth/login")) {
        return Bandwidth.classic(5, Refill.greedy(5, Duration.ofMinutes(1)));
    }
    if (req.getMethod().equals("GET")) {
        return Bandwidth.classic(120, Refill.greedy(120, Duration.ofMinutes(1)));
    }
    return Bandwidth.classic(30, Refill.greedy(30, Duration.ofMinutes(1)));
}

Esta granularidad multiplica las posibilidades. Por ejemplo, podemos aplicar 1000 req/h por API key empresarial y 100 req/h por API key gratuita, leyendo el plan del cliente desde una caché.

Métricas y observabilidad

Bucket4j expone métricas a través de Micrometer. Activar la integración añade contadores bucket4j_consumed_total y bucket4j_rejected_total con etiquetas por nombre de bucket.

BucketProxy bucket = proxyManager
    .builder()
    .withMillisecondPrecision()
    .build(key, configSupplier);

Resilience4j publica resilience4j_ratelimiter_calls_total con las dimensiones kind=successful_without_retry o kind=failed_with_retry, fáciles de graficar en Grafana.

Vigila la ratio rejected / total. Una subida brusca indica abuso, mientras que una ratio constantemente alta puede significar que el cupo está mal dimensionado y conviene ampliarlo.

Errores frecuentes al implementar rate limiting

  • No identificar correctamente al cliente: usar IP detrás de un balanceador equivale a aplicar el cupo a toda la organización del cliente. Lee la cabecera X-Forwarded-For con RemoteIpFilter.
  • No persistir buckets entre reinicios: en endpoints muy críticos conviene usar Redis o un proxy externo para evitar que un reinicio resetee los cupos.
  • Compartir el mismo bucket entre login y endpoints autenticados: un atacante puede agotar el cupo del usuario haciéndole un DoS dirigido. Usa buckets independientes.
  • Olvidar el Retry-After: clientes mal configurados reintentan en bucle, lo que agrava el problema.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Security

Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Aplicar rate limiting en endpoints Spring Security usando Bucket4j con almacenamiento en memoria o Redis, integrar Resilience4j RateLimiter como decorador y devolver respuestas HTTP 429 con cabeceras Retry-After.