SF
{ }
Symfony · Rate Limiter · API Security · PHP 8.4
Symfony Rate Limiter:
APIs vor Missbrauch schützen

Eine öffentlich erreichbare API ohne Ratenbegrenzung ist eine offene Einladung für Brute-Force-Angriffe, Credential Stuffing und automatisierte Datenabfragen. Der Symfony Rate Limiter bietet drei verschiedene Algorithmen – Token Bucket, Sliding Window und Fixed Window – und lässt sich in Minuten in jeden Controller, Service oder Event-Listener integrieren.

16 Min. Lesezeit Token Bucket · Sliding Window · Fixed Window · IP-Throttling Symfony 7.x · PHP 8.4 · Redis · Doctrine

1. Warum Rate Limiting unverzichtbar ist

Jede öffentlich erreichbare API, jedes Login-Formular und jeder Registrierungsendpunkt ist ohne Symfony Rate Limiter ein potenzielles Angriffsziel. Credential Stuffing – das automatisierte Durchprobieren von Benutzername-Passwort-Kombinationen aus geleakten Datenbanken – funktioniert genau deshalb so gut, weil die meisten Anwendungen keine Ratenbegrenzung auf Anmeldeversuche haben. Ein Angreifer mit einer Liste von 10 Millionen Zugangsdaten braucht bei 100 Versuchen pro Sekunde weniger als zwei Tage, um die gesamte Liste durchzuprobieren – wenn keine Gegenwehr existiert.

Neben Credential Stuffing gibt es weitere Bedrohungsszenarien: das systematische Abfragen von Produktpreisen oder Nutzerdaten durch Konkurrenten, DoS-ähnliche Last durch schlecht geschriebene Client-Implementierungen und das automatisierte Anlegen von Fake-Accounts bei Registrierungsendpunkten. Der Symfony Rate Limiter, eingeführt in Symfony 5.2 und seither erheblich erweitert, löst alle diese Szenarien mit einem einheitlichen API. Die Konfiguration erfolgt deklarativ in YAML, die Anwendung im Code mit wenigen Zeilen – und das Ergebnis ist eine Anwendung, die planbar auf Angriffe reagiert, statt sie still durchzulassen.

2. Die drei Algorithmen im Vergleich

Der Symfony Rate Limiter implementiert drei grundlegend verschiedene Algorithmen mit unterschiedlichem Verhalten bei Lastspitzen. Der Token Bucket-Algorithmus arbeitet mit einem virtuellen Eimer voller Token: Jede Anfrage verbraucht einen Token, der Eimer wird mit konstanter Rate nachgefüllt. Das erlaubt moderate Bursts – kurze Phasen mit mehr Anfragen als das langfristige Limit – solange der Eimer nicht leer ist. Das ist ideal für APIs, die gelegentliche Lastspitzen erlauben sollen, ohne die Gesamtrate zu überschreiten.

Das Fixed Window-Modell teilt die Zeit in feste Fenster auf – etwa 60 Anfragen pro Minute. Das Problem: An der Fenstergrenze sind theoretisch doppelt so viele Anfragen möglich, wenn 60 Anfragen am Ende des ersten Fensters und sofort 60 am Anfang des zweiten Fensters kommen. Das Sliding Window-Modell löst dieses Problem durch ein gleitendes Zeitfenster, das bei jeder Anfrage neu berechnet wird. Es ist exakter, aber ressourcenintensiver. Für Login-Schutz und kritische Endpunkte ist Sliding Window die robustere Wahl, für öffentliche API-Limits reicht Fixed Window in den meisten Fällen.


<?php
// config/packages/rate_limiter.yaml — three algorithm examples for Symfony Rate Limiter

// framework:
//   rate_limiter:
//
//     # Token Bucket: allows bursts, refills at constant rate
//     api_anonymous:
//       policy: token_bucket
//       limit: 60          # max tokens in the bucket
//       rate: { interval: '1 minute', amount: 10 }  # refill 10 tokens/min
//
//     # Sliding Window: smooth limit without burst spikes at window boundaries
//     login_limiter:
//       policy: sliding_window
//       limit: 5           # max 5 attempts
//       interval: '15 minutes'
//
//     # Fixed Window: simplest algorithm, minimal storage overhead
//     registration_limiter:
//       policy: fixed_window
//       limit: 3           # max 3 registrations
//       interval: '1 hour'

// Storage backends (per limiter or global):
// framework:
//   rate_limiter:
//     login_limiter:
//       policy: sliding_window
//       limit: 5
//       interval: '15 minutes'
//       lock_factory: lock.default.factory  # prevents race conditions

3. Installation und Grundkonfiguration

Der Symfony Rate Limiter kommt als separates Paket, das über Composer installiert wird. Das Symfony Flex Recipe legt die Grundkonfiguration automatisch an. Für produktiven Einsatz braucht man zusätzlich ein persistentes Storage-Backend – ohne Redis oder Doctrine würden alle Zähler bei jedem Request-Prozess zurückgesetzt. Die Lock-Factory verhindert Race Conditions: Ohne Lock könnte in seltenen Fällen ein Burst an gleichzeitigen Anfragen den Limiter umgehen, weil zwei Prozesse gleichzeitig den aktuellen Zählerstand lesen, bevor einer von ihnen ihn erhöht hat.

Die Namenskonvention für Limiter-Services ist wichtig: Ein Limiter mit dem Namen login_limiter in der YAML-Konfiguration wird als Service limiter.login_limiter im Container registriert. In Controllern und Services injiziert man ihn über den Typ RateLimiterFactory kombiniert mit dem Attribut #[Target('loginLimiter')] in Symfony 7, oder über den klassischen Service-Alias. Die Factory erstellt für jede eindeutige Identität – IP-Adresse, Benutzer-ID oder eine Kombination – einen separaten Limiter-Zustand, der im konfigurierten Backend persistiert wird.

4. Rate Limiter im Controller einsetzen

Im Controller ist der Einsatz des Symfony Rate Limiter auf wenige Zeilen reduziert. Die RateLimiterFactory erstellt über die Methode create() einen Limiter für eine spezifische Identität – typischerweise die IP-Adresse des Clients. consume(1) verbraucht ein Token und gibt ein RateLimit-Objekt zurück, das den aktuellen Zustand des Limiters beschreibt: verbleibende Tokens, Reset-Zeitpunkt und ob das Limit überschritten wurde. Wenn isAccepted() false zurückgibt, sendet man eine 429-Antwort mit dem Retry-After-Header.

Ein häufiger Fehler: Den Limiter-Status nur prüfen, aber nicht bei jedem Request konsumieren. Das führt dazu, dass ein Angreifer unbegrenzt Anfragen senden kann, solange er die Grenze nicht überschreitet. Die korrekte Implementierung mit dem Symfony Rate Limiter ruft immer consume() auf – auch wenn die Anfrage legitimate ist. Erst so wird der Zähler korrekt dekrementiert und das Limit wirksam. Für besonders sensitive Endpunkte kann man consume() mit einem höheren Wert aufrufen, um teure Operationen stärker zu gewichten.


<?php

declare(strict_types=1);

namespace App\Controller\Api;

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;

/**
 * API controller with integrated Symfony Rate Limiter protection.
 */
final class ProductSearchController extends AbstractController
{
    public function __construct(
        // 'api_anonymous' matches the limiter name in rate_limiter.yaml
        private readonly RateLimiterFactory $apiAnonymousLimiter,
    ) {}

    #[Route('/api/products/search', methods: ['GET'])]
    public function search(Request $request): JsonResponse
    {
        // Create a per-IP limiter instance
        $limiter = $this->apiAnonymousLimiter->create($request->getClientIp());

        // Consume 1 token — always called, even for legitimate requests
        $limit = $limiter->consume(1);

        // Set standard rate-limit headers on every response
        $headers = [
            'X-RateLimit-Limit'     => $limit->getLimit(),
            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
            'X-RateLimit-Reset'     => $limit->getRetryAfter()->getTimestamp(),
        ];

        if (!$limit->isAccepted()) {
            return new JsonResponse(
                ['error' => 'Too many requests. Please retry after ' . $limit->getRetryAfter()->format('H:i:s')],
                Response::HTTP_TOO_MANY_REQUESTS,
                array_merge($headers, ['Retry-After' => $limit->getRetryAfter()->getTimestamp()])
            );
        }

        // ... actual search logic here
        return new JsonResponse(['results' => []], Response::HTTP_OK, $headers);
    }
}

5. Login-Formulare gegen Brute-Force absichern

Login-Formulare sind das häufigste Ziel von Brute-Force- und Credential-Stuffing-Angriffen. Der Symfony Rate Limiter schützt sie am effektivsten mit einer kombinierten Strategie: ein Limit pro IP-Adresse und ein separates Limit pro Benutzername. Das IP-Limit verhindert, dass ein einzelner Host Tausende Versuche durchführt. Das Benutzername-Limit verhindert verteilte Angriffe, bei denen viele verschiedene IPs denselben Benutzernamen angreifen. Beide Limits greifen unabhängig voneinander – wenn eines überschritten wird, wird der Versuch abgelehnt.

Die Integration in Symfonys LoginThrottle-Mechanismus geschieht über den login_throttling-Parameter im Security-Bundle. Symfony verwendet dabei intern den konfigurierten Rate Limiter und erstellt automatisch Limiter-Schlüssel aus IP-Adresse und Benutzername. Das eliminiert duplizierten Code in Custom-Listenern. Für Anwendungen mit eigenem Authentifizierungsfluss – etwa OAuth2-Login oder Two-Factor-Authentication – implementiert man den Limiter direkt im Custom Authenticator oder Event-Subscriber, um den gleichen Schutz zu erreichen.


<?php

declare(strict_types=1);

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;

/**
 * Custom authenticator with dual-layer Rate Limiter: per IP and per username.
 */
final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    public function __construct(
        private readonly RateLimiterFactory $loginLimiter,
    ) {}

    public function authenticate(Request $request): Passport
    {
        $username = $request->request->getString('username');
        $ip       = $request->getClientIp() ?? 'unknown';

        // Dual-key strategy: limit by IP AND by username independently
        $ipLimiter   = $this->loginLimiter->create('ip-' . $ip);
        $userLimiter = $this->loginLimiter->create('user-' . $username);

        $ipLimit   = $ipLimiter->consume();
        $userLimit = $userLimiter->consume();

        if (!$ipLimit->isAccepted() || !$userLimit->isAccepted()) {
            // Do not reveal which limiter triggered — prevents enumeration
            throw new AuthenticationException('Too many login attempts. Please try again later.');
        }

        return new Passport(
            new UserBadge($username),
            new PasswordCredentials($request->request->getString('password'))
        );
    }

    protected function getLoginUrl(Request $request): string
    {
        return '/login';
    }
}

6. API-Endpunkte mit kombinierten Limits schützen

Für öffentliche REST-APIs empfiehlt sich eine Zwei-Ebenen-Strategie mit dem Symfony Rate Limiter: ein globales IP-Limit für unauthentifizierte Anfragen und ein benutzerbasiertes Limit für authentifizierte Clients. Unauthentifizierte Clients erhalten ein engeres Limit – etwa 60 Anfragen pro Minute – während authentifizierte API-Nutzer ein deutlich großzügigeres Kontingent bekommen. Diese Differenzierung setzt einen Anreiz zur Authentifizierung und schützt gleichzeitig gegen anonyme Missbrauchs-Szenarien.

Für APIs mit verschiedenen Operationstypen gewichtet man teure Operationen stärker. Eine einfache GET-Anfrage verbraucht einen Token, eine POST-Anfrage mit umfangreichem Datenbankzugriff verbraucht fünf. So bleibt das Gesamtkontingent fair, ohne dass einfache Leseanfragen durch wenige teure Operationen erschöpft werden. Der Symfony Rate Limiter unterstützt das über den $tokens-Parameter in consume($tokens). In Event-Listenern lässt sich dieser Ansatz zentral implementieren, ohne jeden Controller einzeln anzupassen.

7. Storage-Backends: Redis vs. Doctrine vs. In-Memory

Das Storage-Backend bestimmt, ob der Symfony Rate Limiter in einer Multi-Server-Umgebung korrekt funktioniert. Das Standard-In-Memory-Backend ist für Entwicklung und Tests geeignet, aber in Produktion unbrauchbar: Jeder PHP-Prozess hat seinen eigenen Zählerstand, sodass ein Angreifer mit N Requests das Limit N-fach ausschöpfen kann, wenn N Prozesse involviert sind. Redis als Backend ist die empfohlene Lösung: es ist atomar, schnell und unterstützt TTL-basiertes Ablaufen von Limiter-Zuständen nativ.

Für Projekte ohne Redis-Infrastruktur ist das Doctrine-Backend eine sinnvolle Alternative. Es speichert Limiter-Zustände in einer Datenbanktabelle, die über das automatische Schema-Update von doctrine:schema:update oder eine Migration angelegt wird. Die Performance ist für moderate Anfragevolumen ausreichend, aber bei sehr hohen Lasten kann das Datenbankschema zum Flaschenhals werden. Ein kompromissloser Tipp: Für Login-Throttling reicht Doctrine in den meisten Projekten, für API-Rate-Limiting mit vielen gleichzeitigen Anfragen ist Redis die bessere Wahl.


<?php
// config/packages/rate_limiter.yaml — Redis and Doctrine storage configuration

// 1. Redis storage (recommended for high-traffic APIs)
// services:
//   cache.rate_limiter:
//     parent: 'cache.adapter.redis'
//     tags:
//       - { name: 'cache.pool', provider: 'app.redis' }
//
// framework:
//   rate_limiter:
//     api_anonymous:
//       policy: token_bucket
//       limit: 60
//       rate: { interval: '1 minute', amount: 10 }
//       cache_pool: cache.rate_limiter   # use Redis pool

// 2. Doctrine storage (no additional infrastructure needed)
// framework:
//   rate_limiter:
//     login_limiter:
//       policy: sliding_window
//       limit: 5
//       interval: '15 minutes'
//       storage_service: rate_limiter.storage.doctrine

// 3. Create the Doctrine storage table via migration:
// bin/console doctrine:migrations:diff
// bin/console doctrine:migrations:migrate

// Verify active limiters and their storage:
// bin/console debug:container limiter

8. Standardkonforme 429-Antworten zurückgeben

Der HTTP-Standard definiert den Status-Code 429 (Too Many Requests) zusammen mit dem Retry-After-Header für Ratenbegrenzungen. Eine korrekte 429-Antwort enthält den Zeitpunkt, ab dem der Client erneut Anfragen senden darf, entweder als absoluten UTC-Timestamp oder als Sekunden bis zum Reset. Der Symfony Rate Limiter liefert diesen Zeitpunkt über getRetryAfter() am RateLimit-Objekt. Gut gestaltete Fehlermeldungen im Antwort-Body helfen API-Konsumenten, das Problem sofort zu verstehen: Warum wurde die Anfrage abgelehnt, wie lange muss gewartet werden, und gibt es eine Möglichkeit, ein höheres Limit zu erhalten.

Für JSON-APIs ist die Einbindung in einen Event-Subscriber oder eine ExceptionListener-Klasse sinnvoll, um 429-Antworten einheitlich zu formatieren, ohne in jedem Controller denselben Code zu schreiben. Die RateLimitExceededException – die entsteht, wenn man ensureAccepted() statt consume() nutzt – lässt sich global abfangen und in eine standardisierte JSON-Fehlerantwort umwandeln. Dieses Muster reduziert Boilerplate erheblich und stellt sicher, dass alle Rate-Limiting-Fehler im gesamten Projekt dasselbe Format haben.

9. Algorithmen im direkten Vergleich

Die Wahl des richtigen Rate-Limiter-Algorithmus hängt vom konkreten Anwendungsfall ab. Folgende Tabelle zeigt die wichtigsten Eigenschaften im direkten Vergleich.

Algorithmus Burst-Toleranz Exaktheit Empfohlener Einsatz
Token Bucket Hoch (Bursts erlaubt) Mittel Öffentliche APIs, Mobile Clients
Sliding Window Niedrig (glatt verteilt) Hoch Login-Schutz, kritische Endpunkte
Fixed Window Mittel (Grenzeffekt) Mittel Einfache Quotas, Registrierung
No Limiter Unbegrenzt Nicht für Produktion

Für die Praxis gilt: Token Bucket für allgemeine API-Limits, Sliding Window für sicherheitskritische Endpunkte wie Login, Passwort-Reset und Zwei-Faktor-Authentifizierung. Fixed Window für Kontingente, die sich täglich oder stündlich zurücksetzen – etwa ein tägliches E-Mail-Sendelimit pro Benutzer. Kombiniert man alle drei in einem Projekt, erhält man abgestufte Schutzschichten, die unterschiedliche Angriffsszenarien abdecken.

Mironsoft

Symfony API Security, Rate Limiting und Backend-Architektur

Symfony-APIs gegen Missbrauch absichern?

Wir implementieren mehrschichtige Rate-Limiting-Strategien für Symfony-Projekte – von Login-Schutz über API-Throttling bis zur Redis-basierten Skalierung für hohe Anfragevolumen.

Security-Audit

Analyse bestehender Symfony-APIs auf fehlende Rate-Limiting-Schichten und Brute-Force-Lücken

Implementierung

Rate Limiter mit Redis-Backend, kombinierte IP/User-Limits und standardkonforme 429-Antworten

Monitoring

Grafana-Dashboards für Rate-Limit-Events, Angriffserkennung und Kapazitätsplanung

10. Zusammenfassung

Der Symfony Rate Limiter ist eine robuste, gut in das Framework integrierte Lösung für das Schutzproblem öffentlich erreichbarer APIs und Formulare. Token Bucket erlaubt moderate Bursts und eignet sich für allgemeine API-Limits. Sliding Window bietet exakte Kontrolle ohne Grenzeffekte und ist die beste Wahl für Login-Schutz. Fixed Window ist einfach zu konfigurieren und ideal für periodisch zurücksetzende Kontingente. Alle drei Algorithmen lassen sich kombinieren und mit Redis oder Doctrine persistent betreiben.

Die Implementierung folgt einem klaren Muster: Limiter in YAML konfigurieren, RateLimiterFactory injizieren, create() mit einer eindeutigen Identität aufrufen, consume() bei jeder Anfrage aufrufen und bei Überschreitung eine 429-Antwort mit Retry-After-Header zurückgeben. Mit einem Event-Subscriber lässt sich dieses Muster projektübergreifend standardisieren. Das Ergebnis ist eine Anwendung, die Angriffe planbar begrenzt und legitimen Nutzern zuverlässigen Zugang bietet.

Symfony Rate Limiter — Das Wichtigste auf einen Blick

Algorithmen

Token Bucket für API-Limits, Sliding Window für Login-Schutz, Fixed Window für periodische Kontingente – alle drei in YAML konfigurierbar.

Storage-Backend

Redis für hohe Lasten und Multi-Server-Setups. Doctrine als Alternative ohne zusätzliche Infrastruktur. In-Memory nur für Tests.

Dual-Key-Strategie

IP-Limit + Benutzername-Limit kombinieren – schützt gegen verteilte Angriffe und Credential Stuffing gleichzeitig.

429-Antworten

Retry-After-Header setzen, JSON-Fehlerformat standardisieren und RateLimitExceededException global abfangen – kein Boilerplate pro Controller.

11. FAQ: Symfony Rate Limiter

1Was ist der Symfony Rate Limiter?
Eine Symfony-Komponente für Token Bucket, Sliding Window und Fixed Window Rate Limiting – schützt APIs und Formulare vor Brute-Force, Credential Stuffing und automatisierten Anfragen.
2Welcher Algorithmus für Login-Schutz?
Sliding Window – keine Burst-Spitzen an Fenstergrenzen. 5 Versuche in 15 Minuten per IP und per Benutzername ist ein guter Ausgangspunkt.
3Welches Storage-Backend empfehlt sich?
Redis für hohe Lasten und Multi-Server-Setups. Doctrine als Alternative ohne zusätzliche Infrastruktur. In-Memory nur für Tests.
4Wie korrekte 429-Antworten zurückgeben?
JsonResponse mit HTTP 429, Retry-After-Header aus getRetryAfter() und X-RateLimit-Remaining aus getRemainingTokens() – RFC-konform und für API-Clients sofort auswertbar.
5Unterschiedliche Limits per Benutzerrolle?
Mehrere Limiter-Konfigurationen in YAML anlegen und im Controller je nach Rolle den passenden wählen. Authentifizierte Nutzer erhalten typischerweise ein großzügigeres Kontingent.
6Race Conditions verhindern?
lock_factory in rate_limiter.yaml konfigurieren. Die Symfony Lock-Komponente sorgt für atomare Lese-/Schreiboperationen am Limiter-Zustand.
7consume() vs. ensureAccepted()?
consume() gibt ein RateLimit-Objekt zurück. ensureAccepted() wirft eine RateLimitExceededException. Letzteres eignet sich für globale Exception-Handler ohne Boilerplate pro Controller.
8Rate Limiter in Event-Subscribern?
Ja. Ein kernel.request-Subscriber kann den Limiter zentral prüfen – kein Code-Duplikat in jedem Controller, gilt für alle Endpunkte eines Typs.
9Limiter nach erfolgreichem Login zurücksetzen?
reset() am Limiter-Objekt aufrufen. Nach erfolgreichem Login setzt man den Login-Limiter zurück, damit frühere Fehlversuche den Nutzer nicht bei zukünftigen Logins blockieren.
10Rate Limiter hinter Reverse Proxy?
trusted_proxies in framework.yaml auf die Proxy-IP setzen. Danach liefert getClientIp() die echte Client-IP aus X-Forwarded-For statt der Proxy-Adresse.