StackPractices
intermediate Por Mathias Paulenko

Rate Limiting contra Abuso y Ataques de Fuerza Bruta

Rate limiting como control de seguridad contra fuerza bruta, credential stuffing, scraping y DDoS L7: límites por capas, ajuste por endpoint y gestión del 429.

Temas: security

Visión general

El rate limiting se gana su sitio en una pila de seguridad porque casi todo abuso automatizado comparte un rasgo: el volumen. Un ataque de credential stuffing necesita miles de intentos de login. Un scraper necesita cada página, y rápido. Una inundación L7 necesita más peticiones por segundo de las que planificaste. El limitador corta todo esto de raíz haciendo que el volumen salga caro.

Esta receta trata el rate limiting como una capa defensiva, no como una función de shaping de tráfico. Los algoritmos de implementación (token bucket, sliding window, fixed window) se cubren paso a paso en Rate limiting para APIs y Rate limiting con Redis. Lo que sigue es el lado de la seguridad: qué capa bloquea qué, cómo identificar clientes que el atacante no pueda rotar a bajo costo, qué tan estricto debe ser cada endpoint y qué hacer después de que un cliente sobrepase el límite.

flowchart diagram: Cliente

Cuándo usarlo

Despliega rate limiting cuando te enfrentas a:

  • Ataques de fuerza bruta o credential stuffing contra /login, /auth/token, restablecimiento de contraseña o endpoints de OTP
  • Scraping de catálogos, resultados de búsqueda o datos de usuario a velocidad de máquina
  • Inundaciones L7 de peticiones que pasan por debajo del radar de la protección DDoS de capa de red
  • Abuso de costo, donde un solo cliente quema operaciones caras: búsquedas, exportaciones, llamadas a IA, envío de SMS
  • Filtración de API keys o tokens, cuando una credencial robada se está usando intensivamente desde un único origen

No confíes en él cuando:

  • El ataque es volumétrico (L3/L4) — eso lo absorben las defensas de red de tu CDN o WAF
  • El tráfico viene de un conjunto pequeño de servicios internos confiables que controlas — usa autenticación y cuotas separadas
  • El endpoint es legítimamente de alto volumen, como un health check o un receptor de webhooks — un límite genérico generará falsas alarmas

Elegir límites según el tipo de ataque

Cada abuso pide límites y claves distintas:

AtaqueQué lo disparaLímite recomendadoClave
Login por fuerza brutaFallos repetidos en una cuenta5 intentos/minIP + cuenta
Credential stuffingMuchas cuentas desde un origen10 intentos/minIP (+ fingerprint)
Abuso de OTP / resetVolumen de prueba de códigos3-5 intentos/minCuenta + IP
ScrapingLecturas masivas secuenciales60-100 req/minAPI key / ID de usuario
Inundación L7Tasa de peticiones pura10-20 req/s de ráfagaIP
Abuso de costoLlamadas a operaciones caras10-30/horaID de usuario + endpoint

Dos reglas prácticas: cuanto más sensible es el endpoint, más estricto y específico debe ser el límite; y la clave debe ser algo que el atacante no pueda rotar barato (las IPs son baratas, las cuentas menos, y los tokens verificados todavía menos).

Solución

Token bucket atómico en Redis (Lua)

La trampa clásica es hacer lectura-modificación-escritura en dos viajes: con concurrencia, las peticiones se intercalan y el limitador deja pasar más de lo debido. Ejecuta la comprobación y el decremento de forma atómica dentro de Redis:

-- token_bucket.lua
-- KEYS[1] = clave del límite (p. ej. "rl:login:1.2.3.4")
-- ARGV[1] = capacidad, ARGV[2] = tasa de recarga (tokens/seg)
-- ARGV[3] = timestamp unix actual, ARGV[4] = TTL de la clave (seg)

local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last_refill = tonumber(bucket[2]) or tonumber(ARGV[3])

tokens = math.min(tonumber(ARGV[1]),
  tokens + (tonumber(ARGV[3]) - last_refill) * tonumber(ARGV[2]))

local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return allowed
import time
import redis

r = redis.Redis(host='localhost', port=6379, db=0)
TOKEN_BUCKET = r.register_script(open('token_bucket.lua').read())

def is_allowed(key: str, capacity: int, refill_rate: float) -> bool:
    return bool(TOKEN_BUCKET(keys=[key],
                             args=[capacity, refill_rate, time.time(), 60]))

# Bloquear fuerza bruta en login: 5 intentos/minuto por IP + cuenta
key = f"rl:login:{client_ip}:{account}"
if not is_allowed(key, capacity=5, refill_rate=5/60):
    return Response(status=429, headers={'Retry-After': '12'},
                    body=b"Rate limit exceeded")

Límites por niveles con rate-limiter-flexible (Express)

La superficie de ataque varía por ruta, así que el limitador también debe variar — estricto en auth, permisivo en lecturas:

const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('redis')
  .createClient({ url: process.env.REDIS_URL });

// Endpoints de auth: 5 intentos/min, luego bloqueo de 5 min
const authLimiter = new RateLimiterRedis({
  storeClient: redisClient, keyPrefix: 'auth',
  points: 5, duration: 60, blockDuration: 300,
});

// API general: 100 peticiones/10s
const apiLimiter = new RateLimiterRedis({
  storeClient: redisClient, keyPrefix: 'api',
  points: 100, duration: 10,
});

function createLimiter(limiter, keyFn = (req) => req.ip) {
  return async (req, res, next) => {
    try {
      await limiter.consume(keyFn(req), 1);
      next();
    } catch (rej) {
      const retryAfter = Math.ceil(rej.msBeforeNext / 1000);
      res.set('Retry-After', String(retryAfter));
      res.set('X-RateLimit-Remaining', '0');
      res.status(429).json({ error: 'Too many requests', retryAfter });
    }
  };
}

app.use('/api/', createLimiter(apiLimiter));
// Clave IP + credencial: frena que un origen pruebe muchas cuentas
app.use('/api/auth/',
  createLimiter(authLimiter, (req) => `${req.ip}:${req.body?.email || ''}`));

Límites en el edge con Nginx

Rechazo barato y temprano, antes de que las peticiones lleguen a tu aplicación:

limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
  location /api/auth/ {
    limit_req zone=auth burst=3 nodelay;
    limit_req_status 429;
    limit_req_log_level warn;   # alimenta tu detección de ataques
    proxy_pass http://backend;
  }
  location /api/ {
    limit_req zone=api burst=20 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
  }

  error_page 429 = @ratelimited;
  location @ratelimited {
    add_header Retry-After "60" always;
    return 429 '{"error": "Too many requests", "retryAfter": 60}';
  }
}

Verificar que los límites funcionan

Antes de darlo por terminado, golpea un endpoint por encima de su límite y confirma el comportamiento — un limitador mal configurado en silencio no protege nada:

# Ráfaga de 10 peticiones; la cola debería volver con 429
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} " \
    -X POST https://api.example.com/api/auth/login \
    -d '{"email":"a@b.c","password":"x"}'
done
# Esperado: 200/401 unas pocas veces, luego 429 con Retry-After

Ajuste por endpoint

Empieza conservador y afloja donde los usuarios legítimos se quejen:

  • Endpoints de auth y recuperación — 3-5/min con clave IP + cuenta. Casi nunca ven ráfagas legítimas.
  • Lecturas caras (búsquedas, exportaciones, informes) — 10-30/hora por usuario.
  • Lecturas normales — 100+/min por usuario o token; el daño por scraping aquí es lento de todos modos.
  • Escrituras y mutaciones — 20-50/min; las escrituras masivas dañan más que las lecturas masivas.

Escala la respuesta en vez de bloquear de golpe: primera infracción → 429 con un Retry-After corto; reincidencia → blockDuration más largo; abuso sostenido → manda el origen a tu WAF o a un captcha. Los baneos permanentes de IP son el último recurso — los NAT compartidos y los gateways de operadoras móviles hacen que acabes baneando usuarios inocentes que comparten la dirección.

Lo que funciona

  • Devuelve 429 + Retry-After + cabeceras X-RateLimit-* para que los clientes legítimos retrocedan sin fricción mientras los atacantes no reciben nada útil.
  • Usa la identidad como clave, no solo la IP: ID de usuario autenticado, API key o token de sesión. Limitar solo por IP da falsos positivos en NATs compartidos y falsos negativos frente a botnets.
  • Alerta sobre picos de 429 en tu stack de monitorización — una ráfaga de rechazos suele ser la primera señal de un ataque en curso, no un bug de cliente.
  • Combínalo con WAF y controles de autenticación: el rate limiting frena a los atacantes, pero no los autentica. Úsalo junto a cabeceras de seguridad, autenticación robusta y captcha para reincidentes.
  • Fallo cerrado en endpoints de auth: si Redis cae, deniega por defecto en login y reset, y abre el paso solo en lecturas de bajo riesgo.

Errores comunes

  • Usar solo la IP detrás de un proxy — tras un balanceador, req.ip es la dirección del proxy. Lee X-Forwarded-For correctamente (solo el salto confiable) o los atacantes reinician su presupuesto rotando IPs.
  • Contadores no atómicos — las carreras entre lectura y escritura dejan pasar ráfagas. Usa Lua o una librería que envuelva la comprobación en una sola llamada atómica.
  • El mismo límite en todas partes — un techo global o bien ahoga a usuarios legítimos o deja los endpoints de auth expuestos. Los límites deben ajustarse a la sensibilidad de cada endpoint.
  • Limitar solo en el edge — un servicio interno comprometido o con bugs se salta el gateway. Mantén también límites en la capa de aplicación.
  • Claves que nunca expiran — los timestamps de sliding window y los contadores se acumulan para siempre. Cada clave necesita un TTL.

Lectura adicional

Véase también

Preguntas frecuentes

¿Puede un atacante simplemente rotar IPs para saltarse el rate limiting?

Por eso importa la clave. Los límites por IP fallan frente a botnets y proxies rotativos. Combina IP con identificador de cuenta, fingerprint o token — rotar entonces exige credenciales nuevas, mucho más caras que IPs nuevas.

¿El rate limiting detiene un DDoS?

Ayuda contra inundaciones L7 — peticiones que parecen tráfico real pero llegan demasiado rápido. Los ataques volumétricos L3/L4 necesitan filtrado upstream (CDN, centros de scrubbing) antes de que el tráfico llegue a tus servidores.

¿Qué diferencia hay entre rate limiting, throttling y un WAF?

El rate limiting rechaza peticiones por encima de un umbral (429). El throttling las ralentiza en lugar de rechazarlas. Un WAF inspecciona el contenido — payloads, firmas, patrones. Son complementarios: el WAF mira qué llevan las peticiones y el rate limiting cuántas llegan.

¿Deben tener límites distintos los usuarios autenticados y los anónimos?

Sí. Los autenticados reciben límites por ID de usuario — más justos y más difíciles de burlar. El tráfico anónimo recibe límites por IP con techos más bajos, porque es el grupo de mayor riesgo de abuso.

¿Qué debo registrar cuando se dispara un límite?

La clave que lo disparó, el endpoint, el timestamp y si es reincidente. Alimentar los 429 a tu SIEM convierte al limitador en un sensor de detección — un cliente que sigue chocando contra el muro después de que se le dijo que retrocediera te está diciendo algo.