Tienes un endpoint de login, uno de «recuperar contraseña» o un formulario de contacto público. Funciona bien hasta que alguien escribe un script que le pega 200 peticiones por segundo, ya sea para probar contraseñas, spamear el formulario o simplemente porque tu API es la única sin protección de una lista de objetivos fáciles.
La solución típica es «eso se hace en nginx» o «eso lo pone Cloudflare». Y sí, ahí es donde debería vivir la protección más básica. Pero hay reglas que solo tienen sentido a nivel de aplicación: «5 intentos de login por usuario cada 15 minutos» no lo puedes expresar bien en una regla de nginx, porque nginx no sabe qué usuario es. Symfony sí.
El componente RateLimiter
Viene de serie desde Symfony 5.2, no hay que instalar nada raro:
composer require symfony/rate-limiter
Se configura en config/packages/rate_limiter.yaml:
framework:
rate_limiter:
login_attempts:
policy: 'sliding_window'
limit: 5
interval: '15 minutes'
contact_form:
policy: 'fixed_window'
limit: 3
interval: '1 hour'
api_public:
policy: 'token_bucket'
limit: 100
rate: { interval: '1 minute', amount: 20 }
Cada limitador tiene un nombre (login_attempts, contact_form…) y una
política. Las tres que vas a usar el 95% de las veces:
fixed_window: cuenta peticiones en bloques de tiempo fijos. Simple, pero tiene un problema: si el límite es 3 por hora y alguien manda 3 a las 12:59 y 3 más a las 13:01, ha mandado 6 en 2 minutos y no ha saltado el límite. Vale para casos donde eso no importa.sliding_window: corrige ese problema mirando una ventana móvil real, no bloques fijos. Es la que quieres para límites de seguridad como intentos de login.token_bucket: permite ráfagas controladas y luego un goteo constante. Ideal para APIs donde quieres dejar que un cliente haga varias peticiones seguidas pero no de forma indefinida.
Usarlo en un controlador
use Symfony\Component\RateLimiter\RateLimiterFactory;
class SecurityController extends AbstractController
{
public function __construct(
private RateLimiterFactory $loginAttemptsLimiter,
) {
}
#[Route('/login', methods: ['POST'])]
public function login(Request $request): Response
{
$identifier = $request->request->get('email') ?? $request->getClientIp();
$limiter = $this->loginAttemptsLimiter->create($identifier);
if (!$limiter->consume(1)->isAccepted()) {
return $this->json(
['error' => 'Demasiados intentos. Prueba de nuevo en unos minutos.'],
Response::HTTP_TOO_MANY_REQUESTS
);
}
// lógica normal de login
}
}
Fíjate en $loginAttemptsLimiter: Symfony inyecta automáticamente una factory
llamada <nombre>Limiter por cada limitador que definas en el yaml. No hay
que registrar nada a mano.
El identificador ($identifier) es la clave del asunto: si limitas por IP, un
atacante detrás de un proxy compartido puede bloquear a usuarios legítimos sin querer, y un
atacante con IPs rotativas se salta el límite sin esfuerzo. Limitar por email en un login es
mejor criterio, porque el objetivo real es proteger esa cuenta, no esa IP.
Combinar dos límites (lo que de verdad funciona en producción)
Un único límite casi nunca es suficiente. En un login real conviene combinar:
#[Route('/login', methods: ['POST'])]
public function login(
Request $request,
RateLimiterFactory $loginAttemptsLimiter,
RateLimiterFactory $loginIpLimiter,
): Response {
$email = $request->request->get('email');
$ip = $request->getClientIp();
$byEmail = $loginAttemptsLimiter->create($email)->consume(1);
$byIp = $loginIpLimiter->create($ip)->consume(1);
if (!$byEmail->isAccepted() || !$byIp->isAccepted()) {
$retryAfter = max(
$byEmail->getRetryAfter()?->getTimestamp() ?? 0,
$byIp->getRetryAfter()?->getTimestamp() ?? 0,
);
return $this->json(
['error' => 'Demasiados intentos'],
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => $retryAfter]
);
}
// lógica normal de login
}
El límite por email protege una cuenta concreta de fuerza bruta. El límite por IP, más permisivo pero presente, frena a alguien que prueba muchos emails distintos desde el mismo sitio. Ninguno de los dos por separado cubre ambos escenarios.
Devolver la cabecera correcta
Cuando rechazas una petición por límite superado, el estándar HTTP para esto es
429 Too Many Requests con una cabecera Retry-After indicando
cuántos segundos esperar. El objeto que devuelve consume() ya trae esa
información:
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
$response = $this->json(['error' => 'Límite superado']);
$response->headers->set('Retry-After', (string) $limit->getRetryAfter()->getTimestamp());
$response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);
return $response;
}
Un cliente bien hecho (o tu propio frontend) puede leer esa cabecera y no volver a intentarlo hasta que pase el tiempo indicado, en vez de reintentar a lo loco y empeorar el problema.
Dónde se guarda el estado
Por defecto, el RateLimiter usa el componente Cache de Symfony, que a su vez usa el filesystem. Funciona, pero tiene un problema si tienes varios workers o instancias detrás de un balanceador: cada uno lleva su propia cuenta y el límite real acaba siendo límite × número de instancias.
La solución es apuntar el storage a algo compartido, normalmente Redis:
framework:
rate_limiter:
login_attempts:
policy: 'sliding_window'
limit: 5
interval: '15 minutes'
lock_factory: 'lock.redis.factory'
cache_pool: 'cache.redis'
cache:
pools:
cache.redis:
adapter: cache.adapter.redis
provider: 'redis://localhost'
Si solo tienes un servidor, el filesystem por defecto es suficiente y no merece la pena la complejidad extra de meter Redis solo para esto.
Dónde aplicarlo (y dónde no)
Tiene sentido en:
- Login y recuperación de contraseña (fuerza bruta).
- Formularios públicos sin captcha (contacto, newsletter, comentarios).
- Endpoints de API pública, para que un cliente mal programado no tumbe el servicio a base de reintentos.
- Envío de emails o SMS desde un endpoint (para que no te usen como pasarela gratuita de spam).
No hace falta en:
- Endpoints internos ya protegidos por autenticación y con tráfico controlado.
- Rutas de lectura pública de bajo coste, como un listado con caché delante.
Rate limiting no sustituye a las otras capas
Esto protege tu aplicación de abuso a nivel de lógica de negocio, pero no sustituye un firewall, un WAF o las reglas de Cloudflare que ya deberías tener delante para frenar ataques volumétricos antes de que lleguen siquiera a PHP. Son capas distintas: una frena el tráfico masivo antes de que toque tu servidor, la otra entiende el contexto de tu aplicación (quién es este usuario, qué está intentando hacer) que la capa de infraestructura no puede ver.