Rate limiting is a must for any API or publicly accessible web application. It protects against brute-force attacks on login forms, excessive API usage, and unintentional overload from faulty clients. Since version 5.2, Symfony ships with its own rate limiter component that supports various algorithms.
Installation
ddev exec composer require symfony/rate-limiter
For state storage, Redis (recommended for production) or a database adapter is required:
ddev exec composer require symfony/cache
The Three Algorithms at a Glance
Fixed Window
The simplest algorithm: Within a fixed time window, a maximum of N requests are allowed. The window is completely reset after expiration.
Advantage: Easy to understand and implement. Disadvantage: Allows short bursts at the window boundary (e.g., 50 requests just before expiration + 50 shortly after reset = 100 in a short time).
Sliding Window
Similar to fixed window, but the window slides over time. Past requests are gradually forgotten.
Advantage: More even protection, no burst problem. Disadvantage: Slightly more complex to implement.
Token Bucket
Tokens are refilled at a constant rate (e.g., 10 tokens per minute). Each request consumes one token. When the bucket is empty, the request is rejected.
Advantage: Allows short bursts (when tokens have accumulated) while keeping the long-term rate limited. Disadvantage: Slightly harder to explain.
Configuration in Symfony
In config/packages/rate_limiter.yaml:
framework:
rate_limiter:
# Login protection: 5 attempts per 15 minutes (Fixed Window)
login_limiter:
policy: fixed_window
limit: 5
interval: '15 minutes'
storage_service: null # Default: in-memory, use cache for production
# API throttling: 100 requests per hour (Token Bucket)
api_limiter:
policy: token_bucket
limit: 100
rate:
interval: '1 hour'
amount: 100
# Global search: Sliding Window
search_limiter:
policy: sliding_window
limit: 30
interval: '1 minute'
For Redis as storage:
framework:
cache:
pools:
rate_limiter.storage:
adapter: cache.adapter.redis
provider: 'redis://localhost'
rate_limiter:
login_limiter:
policy: fixed_window
limit: 5
interval: '15 minutes'
storage_service: rate_limiter.storage
Login Protection
The most common use case: brute-force protection for the login form.
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
class LoginController extends AbstractController
{
public function __construct(
private readonly RateLimiterFactory $loginLimiterFactory,
) {
}
#[Route('/login', name: 'login', methods: ['POST'])]
public function login(Request $request): Response
{
// Limiter per IP address
$limiter = $this->loginLimiterFactory->create($request->getClientIp());
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
$retryAfter = $limit->getRetryAfter()->getTimestamp() - time();
return $this->render('security/login.html.twig', [
'error' => sprintf(
'Zu viele Anmeldeversuche. Bitte versuche es in %d Sekunden erneut.',
$retryAfter,
),
]);
}
// Normal login logic...
return $this->render('security/login.html.twig');
}
}
API Throttling with Response Headers
For APIs, it is good practice to return rate limit information in the response headers:
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
public function __construct(
private readonly RateLimiterFactory $apiLimiterFactory,
) {
}
#[Route('/api/products', name: 'api_products', methods: ['GET'])]
public function products(Request $request): JsonResponse
{
$apiKey = $request->headers->get('X-API-Key', $request->getClientIp());
$limiter = $this->apiLimiterFactory->create($apiKey);
$limit = $limiter->consume(1);
$headers = [
'X-RateLimit-Limit' => $limit->getLimit(),
'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
'X-RateLimit-Reset' => $limit->getRetryAfter()?->getTimestamp() ?? 0,
];
if (!$limit->isAccepted()) {
return new JsonResponse(
['error' => 'Too Many Requests', 'retry_after' => $limit->getRetryAfter()?->getTimestamp()],
Response::HTTP_TOO_MANY_REQUESTS,
$headers,
);
}
$products = []; // ... load products
return new JsonResponse(['data' => $products], Response::HTTP_OK, $headers);
}
}
Rate Limiter as an Event Subscriber
A more elegant approach is an event subscriber that handles rate limiting centrally for all API routes:
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;
class ApiRateLimitSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly RateLimiterFactory $apiLimiterFactory,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 20],
];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
$identifier = $request->headers->get('X-API-Key', $request->getClientIp());
$limiter = $this->apiLimiterFactory->create($identifier);
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
$event->setResponse(new JsonResponse(
['error' => 'Rate limit exceeded'],
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => $limit->getRetryAfter()?->getTimestamp()],
));
}
}
}
Testing the Rate Limiter
In the test environment, the rate limiter is often disabled or configured with high limits. In config/packages/test/rate_limiter.yaml:
framework:
rate_limiter:
login_limiter:
policy: fixed_window
limit: 10000 # Effectively disabled for tests
interval: '1 minute'
For targeted tests of the rate limiting behavior:
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class LoginRateLimitTest extends WebTestCase
{
public function testRateLimitAfterFiveAttempts(): void
{
$client = static::createClient();
for ($i = 0; $i < 5; $i++) {
$client->request('POST', '/login', ['_username' => 'test', '_password' => 'wrong']);
}
$client->request('POST', '/login', ['_username' => 'test', '_password' => 'wrong']);
// 6th attempt: rate limit reached
$this->assertStringContainsString('Zu viele Anmeldeversuche', $client->getResponse()->getContent());
}
}
Conclusion
The Symfony Rate Limiter is a clean, flexible solution for API throttling and brute-force protection. The various algorithms cover different requirements. In production, Redis is recommended as storage -- the in-memory adapter loses its state on every restart. If you run multiple load balancer instances, you need centralized storage anyway, since otherwise each instance maintains its own limit.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.