Symfony API-Sicherheit: Authentifizierung, Rate Limiting und Eingabevalidierung in einem produktionsreifen Setup
Die meisten Ratschläge zur Symfony API-Sicherheit behandeln immer nur eine Schicht auf einmal: ein Tutorial zu JWT, eines zu Votern, ein weiteres zu Rate Limiting. In der Produktion hängen diese Schichten voneinander ab, und in den Lücken dazwischen tauchen meist die tatsächlichen Schwachstellen auf. Dieser Beitrag setzt ein vollständiges Symfony API-Sicherheits-Setup zusammen, von der Authentifizierung bis zu den Feldern, die man in einer Response preisgibt, mit den Bausteinen, die wir bei Wolf-Tech in Kundenprojekten einsetzen.
Authentifizierung: JWT für Nutzer, API-Keys für Maschinen
Für die meisten Symfony-APIs ist JWT-Authentifizierung über lexik/jwt-authentication-bundle nach wie vor der richtige Standard. Sie fügt sich mit minimalem Boilerplate in Symfonys Security-Komponente ein und funktioniert gut für zustandslose APIs, die ein Web- oder Mobile-Frontend bedienen.
Was Teams häufig falsch machen, ist die Token-Lebensdauer und -Rotation. Ein übliches Setup gibt einen Access-Token aus, der nach 15 Minuten abläuft, und einen Refresh-Token, der zwei Wochen lang gültig ist, separat gespeichert und bei jeder Nutzung rotiert. Wird ein Refresh-Token verwendet, gibt die API sowohl einen neuen Access-Token als auch einen neuen Refresh-Token aus und invalidiert den alten Refresh-Token sofort. Dieser letzte Schritt ist entscheidend: ohne ihn bleibt ein geleakter Refresh-Token gültig, bis er von selbst abläuft, selbst nachdem der legitime Nutzer schon einen neueren ausgestellt hat.
Symfonys Firewall-Konfiguration stateless: true sorgt dafür, dass das sauber funktioniert. Kein Session-Cookie, kein CSRF-Token, das verwaltet werden muss, und kein serverseitiger Session-Speicher, den man skalieren muss. Die Firewall-Konfiguration für ein typisches Setup sieht so aus:
security:
firewalls:
api:
pattern: ^/api
stateless: true
jwt: ~
JWT ist die richtige Wahl, wenn ein Mensch hinter dem Request steckt. Server-zu-Server-Integrationen, Webhook-Consumer und Partner-API-Zugriff sind ein anderes Problem. Dafür vermeidet eine separate API-Key-Firewall mit gehashten Keys in der Datenbank, geprüft gegen einen dedizierten Authenticator, dass Maschinen-Clients durch einen Token-Refresh-Flow gezwungen werden, den sie nicht brauchen. Die beiden Firewalls auf unterschiedlichen Route-Mustern zu halten, verhindert, dass ein geleakter API-Key gegen nutzerorientierte Endpunkte wiedergegeben wird und umgekehrt.
Bedient die eigene API externe Partner, beginnt unsere individuelle Softwareentwicklung oft genau hier: menschliche und maschinelle Authentifizierung trennen, bevor eine neue Integration hinzukommt, statt API-Keys nachträglich an eine bestehende JWT-Firewall zu schrauben.
Autorisierung: Voter für alles, was spezifischer ist als eine Rolle
Rollenbasierte Zugriffskontrolle deckt die einfachen Fälle ab. Der schwierigere Fall, und derjenige, der tatsächlich Vorfälle verursacht, ist die ressourcenbezogene Autorisierung: darf dieser authentifizierte Nutzer diese konkrete Rechnung bearbeiten, oder nur Rechnungen, die zur eigenen Organisation gehören. Genau dafür gibt es Symfonys Security Voter.
Ein Voter für den Rechnungszugriff prüft das Subjekt gegen die Organisation des aktuellen Nutzers, nicht nur gegen seine Rolle:
class InvoiceVoter extends Voter
{
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $subject->getOrganization() === $user->getOrganization();
}
}
Sobald mehr als ein Voter auf dieselbe Ressource zugreift, entscheidet die Strategie des AccessDecisionManager, wie ihre Stimmen kombiniert werden. Die Standardstrategie affirmative gewährt Zugriff, sobald ein Voter zustimmt, was für sicherheitskritische Ressourcen meist falsch ist: ein einzelner zu großzügiger Voter kann damit jede andere Prüfung außer Kraft setzen. Für alles, was Abrechnung, personenbezogene Daten oder Kontoeinstellungen betrifft, wechselt man zu unanimous, was verlangt, dass jeder zuständige Voter zustimmt.
security:
access_decision_manager:
strategy: unanimous
Das ist die Schicht, die "der Nutzer ist eingeloggt" von "der Nutzer darf genau das tun" trennt, und es ist diejenige, die bei den meisten Audits komplett fehlt, mit Autorisierungslogik verstreut über Controller statt zentral in Votern, wo sie sich tatsächlich testen lässt.
Rate Limiting: die API vor den eigenen Clients schützen
Authentifizierung und Autorisierung beantworten, wer anruft. Rate Limiting beantwortet, wie oft anrufen erlaubt ist. Die Komponente symfony/rate-limiter mit einem Redis-Backend übernimmt das, ohne dass der API-Server selbst State mitführen muss, was relevant wird, sobald mehr als eine Instanz hinter einem Load Balancer läuft.
Ein Sliding-Window-Limiter, konfiguriert pro Endpunkt, sieht so aus:
framework:
rate_limiter:
api_write:
policy: sliding_window
limit: 60
interval: '1 minute'
api_read:
policy: sliding_window
limit: 300
interval: '1 minute'
Lese- und Schreiblimits zu trennen ist wichtig, weil ein Client, der einen Suchendpunkt malträtiert, nicht dasselbe Budget verbrauchen sollte wie einer, der Bestellungen aufgibt. Ist ein Limit erreicht, gibt man einen 429 mit Retry-After- und X-RateLimit-Remaining-Headern zurück statt eines nackten Fehlers. Gut gebaute API-Clients lesen diese Header und drosseln sich automatisch, was Retry-Stürme bei Traffic-Spitzen deutlich effektiver eindämmt als ein strengeres Limit es könnte.
Rate Limiting allein nach IP-Adresse bricht hinter geteilten Firmennetzwerken und Mobilfunk-NAT zusammen, wo Hunderte legitimer Nutzer sich eine Adresse teilen. Den Limiter am authentifizierten Nutzer oder API-Key festzumachen und nur bei anonymen Requests auf die IP zurückzufallen, liefert ein deutlich genaueres Bild von tatsächlichem Missbrauch.
Eingabevalidierung: schlechte Daten abfangen, bevor sie die eigene Domäne erreichen
JWT und Voter halten Leute fern, die dort nichts zu suchen haben. Validierung hält Daten fern, die dort nichts zu suchen haben, selbst von jemandem, der ansonsten jedes Recht hat, den Request zu stellen.
Symfonys Validator-Komponente, angewendet über Constraint-Attribute auf den eigenen DTOs, übernimmt die strukturellen Prüfungen: Pflichtfelder, String-Länge, numerische Bereiche, gültige Enum-Werte. Wird die API mit API Platform gebaut, läuft diese Validierung automatisch als Teil der Deserialisierung, bevor die eigene Business-Logik das Objekt überhaupt zu Gesicht bekommt. Ein Request mit fehlendem Pflichtfeld oder einem zu langen String wird an der Framework-Grenze mit einem 422 und einer feldbezogenen Fehlermeldung abgelehnt, nicht drei Schichten tief in einer Service-Klasse.
class CreateInvoiceRequest
{
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $reference;
#[Assert\Positive]
public int $amountCents;
#[Assert\Choice(choices: ['EUR', 'USD', 'GBP'])]
public string $currency;
}
Das deckt Struktur ab, aber nicht Geschäftsregeln, die von Zustand abhängen, etwa die Prüfung, dass ein Rechnungsbetrag das Kreditlimit eines Kunden nicht überschreitet. Solche Prüfungen gehören in eine Service-Schicht, die nach der Basisvalidierung läuft, da sie meist einen Datenbank-Lookup brauchen, den ein Constraint-Attribut nicht selbst durchführen kann.
Ein Code-Review ist oft die Stelle, an der solche Lücken zuerst auffallen: ein Validierungs-Constraint, das die Länge prüft, aber nicht das Format, ein Voter, der für einen Controller hinzugefügt, aber nie auf die API-Version derselben Ressource angewendet wurde. Wer sich vor einem Sicherheitsvorfall noch einen zweiten Blick auf eine bestehende API wünscht, findet genau diese Art von Review im Rahmen unserer Code-Qualitätsberatung.
Die API dokumentieren, ohne ungewollt Felder preiszugeben
NelmioApiDocBundle generiert eine OpenAPI-Spezifikation direkt aus den eigenen Route-Annotationen und DTOs, wodurch die Dokumentation nicht aus dem Takt mit der tatsächlichen API gerät, ein verbreitetes Problem bei handgepflegter Doku. Diese Spezifikation wird besonders nützlich für Partner-Integrationen, wo ein präziser, maschinenlesbarer Vertrag das Hin und Her beim Klären von Request- und Response-Formaten per E-Mail reduziert.
Was man vor der Veröffentlichung dieser Spezifikation richtig hinbekommen sollte, sind Serializer-Gruppen. Ohne sie gibt Symfonys Serializer jede öffentliche Property einer Entity preis, einschließlich solcher, die man nie über die API zurückgeben wollte: interne Notizen, die E-Mail eines anderen Nutzers auf einer geteilten Ressource, ein Soft-Delete-Flag. Explizite Gruppen pro Rolle zu definieren bedeutet, dass ein Admin-Endpunkt und ein öffentlicher Endpunkt unterschiedliche Formen derselben Entity zurückgeben können, mit internen Feldern, die in der öffentlichen Gruppe schlicht fehlen, statt nachträglich herausgefiltert zu werden.
#[Groups(['invoice:read', 'invoice:admin'])]
private string $reference;
#[Groups(['invoice:admin'])]
private ?string $internalNotes = null;
Das ist ein Fall, in dem das Sicherheitsmodell die Arbeit übernimmt, die ein Code-Review sonst manuell erkennen müsste: ein neu hinzugefügtes Feld an der Entity ist für die API standardmäßig unsichtbar, bis jemand es bewusst zu einer Gruppe hinzufügt, statt standardmäßig sichtbar zu sein, bis jemand daran denkt, es zu verstecken.
Alles zusammen
Keine dieser vier Schichten ersetzt die anderen. JWT und API-Keys stellen die Identität fest. Voter entscheiden, worauf diese Identität zugreifen darf. Rate Limiting schützt die API davor, überlastet zu werden, egal ob durch Angreifer oder einen fehlerhaften Client. Validierung und Serializer-Gruppen kontrollieren, welche Daten reingehen und welche rauskommen. Eine Lücke in einer dieser Schichten wird meist von demjenigen entdeckt, der sie zuerst findet, und das ist selten das eigene API-Team.
Wer eine neue Symfony-API baut oder eine bestehende vor dem Sicherheits-Review eines Enterprise-Kunden härten will: Wolf-Tech hat dieses Setup oft genug gemacht, um zu wissen, wo die Lücken meist stecken. Erreichbar unter hello@wolf-tech.io oder auf wolf-tech.io, um das eigene Setup durchzusprechen.

