Zum Inhalt springen

Symfony Rate Limiter: API Throttling with Token Bucket and Fixed Window

Veröffentlicht am Mar 25, 2025 | ca. 2 Min. Lesezeit |

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.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.