Blog
Symfony Security — Wenn Firewalls und Authentifizierung miteinander kollidieren
Das Login funktioniert in einem Bereich, während die API 401 oder einen HTML-Redirect liefert. Oft greift ein anderer Firewall, Authenticator oder eine andere access_control-Regel als erwartet.
Im Adminbereich angemeldet, von der API mit 401 abgewiesen
Angenommen, die Anmeldung an einer Symfony-Verwaltungsoberfläche funktioniert: Das Dashboard und geschützte HTML-Seiten lassen sich öffnen. Derselbe Browser stellt anschließend diese Anfrage:
GET /api/admin/pages HTTP/1.1
Host: app.example.com
Accept: application/jsonDie API antwortet:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="admin-api"
Content-Type: application/json
{"error":"authentication_required"}Hier erwartet die API einen Bearer-Token, während die Anmeldung im Adminbereich eine Sitzung aufbaut. Dass beide Anfragen aus demselben Browser kommen, sagt noch nichts darüber aus, ob die API diese Sitzung akzeptiert.
Die Beispiele beziehen sich auf Symfony 7.4 und PHP ab Version 8.2. Die Konfigurationsausschnitte sind unvollständig und müssen zur eingesetzten Version sowie zum Authentifizierungskonzept der Anwendung passen.
Zuerst die passende Firewall bestimmen
Als Erstes würde ich prüfen, welche Firewall /api/admin/pages tatsächlich verarbeitet. Eine Symfony-Firewall bündelt die Authentifizierungseinstellungen für passende Anfragen. Dabei zählt der erste Treffer in der Konfiguration, auch wenn ein späteres Muster genauer passt.
Dieser unvollständige Ausschnitt enthält absichtlich eine falsche Reihenfolge:
security:
firewalls:
main:
pattern: ^/
lazy: true
api:
pattern: ^/api
stateless: truemain erfasst /api/admin/pages; api wird gar nicht mehr berücksichtigt. Auch lazy: true ändert daran nichts. Bei einer beabsichtigten Trennung gehört das spezifische Muster vor das allgemeine. Anschließend sind Authentifikator, Benutzeranbieter und Zugriffsregeln der gewählten Firewall zu prüfen. Allein durch das Umordnen entsteht noch keine Bearer-Authentifizierung.
Die gewählte Firewall kann eine Anmeldung aus der Sitzung wiederherstellen oder passende Authentifikatoren heranziehen. Eine öffentliche Anfrage darf gegebenenfalls anonym bleiben. Symfonys Security-Token bildet die erkannte Identität ab und ist vom Bearer-Token im HTTP-Header zu unterscheiden. Berechtigungsprüfungen können vor dem Controller über access_control, durch Controller-Attribute wie #[IsGranted] und an ausdrücklichen Aufrufstellen im Code erfolgen.
Danach lässt sich klären, welche Zugangsdaten diese Firewall erwartet. Eine mögliche Aufteilung ist:
/admin Sitzungscookie stateful
/api Authorization: Bearer <token> statelessBei diesem Aufbau stellt die API-Firewall mit stateless: true keine Authentifizierung aus der Admin-Sitzung wieder her. Sendet der Browser nur das Sitzungscookie, erklärt das den 401. Eine vom Browser aufgerufene Admin-API kann ebenso Sitzungen verwenden; der Präfix /api schreibt keines der beiden Modelle vor.
Getrennte zustandsbehaftete Firewalls können denselben context nutzen, wenn Sitzungsverarbeitung und Benutzeranbieter zusammenpassen. Bei allen Beteiligten muss stateless: false gelten. Eine unabhängige zustandslose Firewall übernimmt diese Sitzungsanmeldung nicht.
Prüfen, welcher Authentifikator zuständig ist
Die Methode supports() entscheidet, ob ein bestimmter Authentifikator die Anfrage bearbeiten soll. Eine Bedingung wie str_starts_with($request->getPathInfo(), '/api') erfasst auch /apiary und prüft nicht einmal, ob Zugangsdaten vorliegen.
Die folgende Methode gehört zu einem beispielhaften Authentifikator für mitgesendete Authorization-Header unter /api. Seine Authentifizierungslogik akzeptiert Bearer und weist andere Verfahren oder ungültige Token zurück. Request steht für Symfony\Component\HttpFoundation\Request; der übrige Teil der Klasse ist ausgelassen:
public function supports(Request $request): bool
{
$path = $request->getPathInfo();
$isApiPath = $path === '/api' || str_starts_with($path, '/api/');
return $isApiPath && $request->headers->has('Authorization');
}false überspringt diesen Authentifikator; die Zugriffsregeln der Route gelten weiterhin. true wählt ihn zur Authentifizierung aus, bestätigt aber noch keine Token-Gültigkeit. Unterstützt ein anderer Authentifikator ein weiteres Authorization-Verfahren, müssen die Auswahlbedingungen diese Verfahren unterscheiden.
Auch eine öffentliche Route kann eine optionale Authentifizierung anbieten. PUBLIC_ACCESS verhindert die Ausführung eines Authentifikators nicht. Ungültige Zugangsdaten können deshalb selbst dort einen Authentifizierungsfehler auslösen. Bei mehreren Authentifikatoren sind überschneidende supports()-Bedingungen und die jeweilige Fehlerbehandlung zu prüfen.
Benötigt eine geschützte Anfrage eine Anmeldung, legt der konfigurierte Einstiegspunkt fest, wie der Client sie beginnen soll: etwa durch eine Weiterleitung für HTML oder eine 401-Antwort für die API. Den JSON-Inhalt bestimmt die Schnittstelle. Symfony 7.4 kann einen einzelnen geeigneten Einstiegspunkt automatisch auswählen; bei mehreren Kandidaten ist entry_point ausdrücklich zu setzen. Lehnt ein Authentifikator Zugangsdaten ab, kann seine Fehlerbehandlung direkt antworten.
Wenn eine JSON-Anfrage HTML erhält
Ein fetch im Browser folgt Weiterleitungen standardmäßig. In diesem Beispiel entstehen zwei getrennte Antworten:
HTTP/1.1 302 Found
Location: /login
HTTP/1.1 200 OK
Content-Type: text/htmlresponse.json() versucht dann, die Anmeldeseite als JSON zu lesen. Der Parserfehler kann das Authentifizierungsproblem verdecken, obwohl der abschließende Status 200 lautet. Dieser kurze Diagnosecode für den Browser prüft die Antwort vor dem Parsen:
async function loadAdminPages() {
const response = await fetch('/api/admin/pages', {
headers: { Accept: 'application/json' },
});
if (response.redirected) {
throw new Error(
'Unerwartete Weiterleitung; Authentifizierung prüfen.',
);
}
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const mediaType = response.headers.get('content-type')
?.split(';')[0].trim().toLowerCase();
if (mediaType !== 'application/json') {
throw new Error(
'Antwort mit application/json erwartet.',
);
}
return response.json();
}Accept fordert ein Format an, konfiguriert aber nicht Symfonys Einstiegspunkt. Der Ausschnitt erwartet application/json; für einen anderen JSON-Medientyp muss die Prüfung angepasst werden. Zulässige Cookies zum selben Ursprung werden standardmäßig mitgesendet. Einen Bearer-Token liefert der Code jedoch nicht, sodass die API aus dem Eingangsbeispiel ihn weiterhin abweisen würde.
response.redirected erkennt eine Weiterleitung erst nach ihrer Ausführung. Soll fetch Weiterleitungen gar nicht folgen, setzt man redirect: 'error' und behandelt das abgelehnte Promise.
Identität und Berechtigung getrennt prüfen
Authentifizierung stellt die Identität des Aufrufers fest. Autorisierung entscheidet, ob er die gewünschte Operation ausführen darf. Eine gültige Sitzung oder ein gültiger Token berechtigt nicht automatisch zum Löschen einer Seite.
Wie bei Firewalls zählt unter access_control die erste passende Regel. Für eine ausschließlich Administratoren vorbehaltene API ist dieser Ausschnitt absichtlich falsch:
security:
access_control:
- { path: ^/api, roles: PUBLIC_ACCESS }
- { path: ^/api/admin, roles: ROLE_ADMIN }Für /api/admin/pages wird nur die öffentliche Regel ausgewählt. Die beabsichtigte Anforderung ROLE_ADMIN greift damit auf Ebene von access_control nicht mehr. Ein Controller-Attribut oder eine ausdrückliche Berechtigungsprüfung kann die Operation weiterhin schützen, doch die Einschränkung aus der zweiten Zeile ist wirkungslos. Die spezifische Regel gehört nach vorn; öffentlicher Zugriff bleibt auf dafür vorgesehene Ressourcen beschränkt.
Die Rollen sind gesondert zu betrachten. Mit dieser Konfiguration erhält ein Administrator über die Hierarchie auch die Berechtigungen eines Redakteurs:
security:
role_hierarchy:
ROLE_ADMIN:
- ROLE_EDITORRedakteure erhalten dadurch keine Administratorrechte. Die Hierarchie wird über Symfonys Berechtigungsprüfungen berücksichtigt. Der unmittelbare Rückgabewert von getRoles() ist dagegen nicht mit der vollständigen Menge wirksamer Berechtigungen gleichzusetzen.
Die Rolle allein muss dennoch nicht ausreichen. Eine Löschregel für Seiten könnte beispielsweise verlangen, dass der Benutzer Eigentümer ist und dem zuständigen Team angehört sowie dass die Seite zum aktuellen Mandanten gehört und noch ein Entwurf ist. Ein Controller-Aufruf wie $this->denyAccessUnlessGranted('PAGE_DELETE', $page) fordert diese objektbezogene Entscheidung an. Ein Voter wirkt mit, wenn er das Attribut und das Prüfobjekt unterstützt; Entscheidungsstrategie und weitere Voter beeinflussen das Ergebnis. Eine ausgeblendete Löschschaltfläche in React setzt die Regel nicht durch.
Der Status ist im Zusammenhang mit dem vorgesehenen Antwortverhalten zu lesen:
401 Unauthorizedbezeichnet üblicherweise fehlende oder ungültige Zugangsdaten für die Zielressource. HTTP verlangt eineWWW-Authenticate-Challenge; das Eingangsbeispiel verwendet Bearer.403 Forbiddenbedeutet, dass der Server die Anfrage ablehnt. Daraus folgt nicht, dass ein Benutzer authentifiziert wurde: Auch anonyme Anfragen, CSRF-Fehler oder andere Richtlinien können zur Ablehnung führen.- Eine Weiterleitung zur Anmeldung kann für HTML richtig sein. Entscheidend sind die ursprüngliche Antwort und die Weiterleitungskette, nicht nur die zuletzt angezeigte Seite oder der JSON-Parserfehler.
Browser und Next.js-Server stellen unterschiedliche Anfragen
Funktioniert die Navigation, aber nicht das Neuladen, sollten die Ausführungsorte beider API-Aufrufe verglichen werden. Eine Server Component oder ein Route Handler sendet eine eigene Anfrage an Symfony. Auch die Navigation im App Router kann serverseitige Verarbeitung auslösen; die Art der Navigation allein verrät deshalb nicht, wo fetch läuft.
Browser -> Symfony
Cookies: gemäß Geltungsbereich und credentials-Modus.
Bearer: ausdrücklich in Authorization gesetzt.
Browser -> Next.js -> Symfony
Symfony erhält nur die Zugangsdaten, die der Server
seiner eigenen ausgehenden Anfrage hinzufügt.Ein serverseitiger fetch übernimmt weder Cookies noch den Authorization-Header der eingehenden Browseranfrage automatisch. credentials: 'include' leitet sie ebenfalls nicht weiter. Der Anfragekontext muss über die APIs der eingesetzten Next.js-Version ausgelesen werden; an das vertrauenswürdige Symfony-Ziel gehören nur die vorgesehenen Zugangsdaten. Alle Header zu kopieren, ein vom Aufrufer bestimmtes Ziel zuzulassen oder Benutzerantworten über einen gemeinsamen Cache auszuliefern, würde zusätzliche Sicherheitsprobleme schaffen.
Geltungsbereich von Cookies
Bei einer Browseranfrage zählt das Cookie, das tatsächlich an die API gesendet wird. Ein Ursprung (Origin) besteht aus Schema, Host und Port. Cookies folgen anderen Geltungsregeln und sind nicht durch den Port voneinander getrennt:
Path=/adminschließt/apiaus. Der Pfad steuert das Mitsenden, ist aber keine Autorisierungsgrenze.- Ohne
Domaingilt ein Cookie nur für den Host, der es gesetzt hat. Eine zulässige übergeordnete Domain erweitert den Geltungsbereich auf Subdomains und damit auch die Angriffsfläche. Securebeschränkt die Übertragung auf sichere Verbindungen, abgesehen von Browserausnahmen für lokale Entwicklung.HttpOnlyverhindert das Auslesen durch JavaScript, nicht das Mitsenden beifetch.SameSitebezieht sich auf Websites, nicht allein auf Origins. Zwei HTTPS-Subdomains können zur selben Site gehören und dennoch unterschiedliche Origins haben. Websiteübergreifende Cookies mitSameSite=NonebenötigenSecure; Browser-Datenschutzregeln können sie trotzdem blockieren.
Ein ausdrücklich gesetztes SameSite=Lax erlaubt bestimmte siteübergreifende Navigationen auf oberster Ebene mit sicheren HTTP-Methoden, etwa das Öffnen eines GET-Links. Diese Ausnahme gilt nicht für fetch. Strict schließt siteübergreifendes Mitsenden aus. credentials: 'include' hebt weder diese Vorgaben noch Host- und Pfadgrenzen auf. Der Cookie-Geltungsbereich sollte auf den vorgesehenen Sitzungsablauf beschränkt bleiben; in Produktion ist HTTPS erforderlich.
Ursprungsübergreifende Anfragen und CSRF
Angenommen, der Browser ruft https://api.example.com von https://frontend.example.com aus auf. Die Origins unterscheiden sich. Cookie-Authentifizierung benötigt dann credentials: 'include', ein zulässiges Cookie und eine CORS-Antwort, die den Frontend-Ursprung erlaubt. Folgende Antwortheader zeigen genau diesen Aufbau:
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Credentials: true
Vary: OriginVor der Rückgabe wird der Ursprung gegen eine Positivliste geprüft; * erlaubt keinen Browserzugriff mit Zugangsdaten. Ist eine Vorabanfrage nötig, muss die Antwort auch die vorgesehene Methode und die benötigten Header zulassen, insbesondere ausdrücklich Authorization, falls verwendet. Das vorangestellte OPTIONS enthält weder Sitzungscookie noch Bearer-Token der eigentlichen Anfrage. Ein 401 dort kann den API-Aufruf daher bereits vor dem Senden verhindern.
CORS regelt den Browserzugriff auf Antworten anderer Origins und das Senden von Anfragen, die eine Vorabprüfung benötigen. Manche Anfragen erreichen den Server trotzdem, auch wenn JavaScript ihre Antwort nicht lesen darf. Die eigentliche Operation braucht daher weiterhin Authentifizierung und Autorisierung. CORS übernimmt keine dieser Aufgaben und beschränkt Server-zu-Server-Aufrufe nicht auf dieselbe Weise.
Bei zustandsändernden Operationen richtet sich der CSRF-Schutz nach der Übertragung der Zugangsdaten. Automatisch gesendete Cookies benötigen geeigneten Schutz, etwa validierte CSRF-Token oder eine sorgfältig entworfene Ursprungsprüfung. Ein ausdrücklich gesetzter Authorization: Bearer-Header verändert das klassische CSRF-Risiko. Wird der Token als Cookie gespeichert und übertragen oder akzeptiert ein Ausweichverfahren automatische Zugangsdaten, bleibt eine Prüfung nötig. HttpOnly allein verhindert CSRF nicht. Global abgeschalteter CSRF-Schutz oder CORS für beliebige Origins beheben diesen Authentifizierungsfehler nicht.
Festlegen, was eine Abmeldung beendet
Das Ende der Admin-Sitzung widerruft nicht zwangsläufig einen unabhängig ausgestellten API-Token. Das Löschen aus localStorage entfernt die Kopie in diesem Browser. Eine andere Kopie kann bis zum Ablauf oder einem unterstützten serverseitigen Widerruf gültig bleiben.
Es muss feststehen, ob die Abmeldung diese Sitzung beendet, API-Token widerruft, die Erneuerung über Refresh-Token verhindert oder all das umfasst. Getestet wird die Reaktion des Servers auf die bisherigen Zugangsdaten, auch nach deren Ablauf.
Ablehnung und erlaubten Zugriff testen
Für die Bearer-API dieses Beispiels gilt: anonymer Lesezugriff ergibt 401, ein Löschversuch des authentifizierten Redakteurs 403 und eine berechtigte Löschung durch den Administrator 204. Diese Statuscodes bilden den angenommenen API-Vertrag.
Die abstrakten Methoden müssen in einer konkreten Testklasse implementiert werden; sie sind keine vorhandenen Symfony- oder Projekthelfer. Benötigt werden isolierte Testbenutzer und Token, die der echte Authentifikator akzeptiert. Jeder Löschtest braucht eine vorhandene, ansonsten löschbare Seite: Dem Redakteur fehlt die Berechtigung, der Administrator erfüllt dagegen Eigentums-, Mandanten- und Zustandsregeln.
pageExists() muss die gespeicherten Daten erneut abfragen. Die Assertions setzen sofortiges Löschen voraus und sind bei Soft Delete oder Warteschlangenverarbeitung anzupassen. Dafür ausschließlich Testdaten und Testzugangsdaten verwenden.
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class AdminApiSecurityTestCase extends WebTestCase
{
abstract protected function editorBearerToken(): string;
abstract protected function adminBearerToken(): string;
abstract protected function deletablePageId(): int;
abstract protected function pageExists(int $pageId): bool;
public function testAnonymousUserCannotAccessAdminApi(): void
{
$client = static::createClient();
$client->request('GET', '/api/admin/pages', server: [
'HTTP_ACCEPT' => 'application/json',
]);
self::assertResponseStatusCodeSame(401);
self::assertResponseHeaderSame(
'WWW-Authenticate',
'Bearer realm="admin-api"',
);
}
public function testEditorCannotDeletePage(): void
{
$client = static::createClient();
$token = $this->editorBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(403);
self::assertTrue($this->pageExists($pageId));
}
public function testAdminCanDeletePage(): void
{
$client = static::createClient();
$token = $this->adminBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(204);
self::assertFalse($this->pageExists($pageId));
}
}Der Redakteurtest prüft die Ablehnung und den Erhalt der Seite, der Administratortest den Erfolg und die tatsächliche Löschung. Zusammen erkennen sie ein System, das einfach jeden abweist. Ein 403 allein würde nicht belegen, dass die beabsichtigte Berechtigungsregel gegriffen hat.
Bei einer sitzungsbasierten API verwendet man einen Testbenutzer mit loginUser($user, $firewallContext) und gegebenenfalls den tatsächlich konfigurierten gemeinsamen Kontext. Diese Hilfsmethode umgeht die Anmeldung und funktioniert nicht mit zustandslosen Firewalls; der echte Anmeldeablauf braucht deshalb einen eigenen Test. Wo CSRF-Schutz vorgesehen ist, sind gültige CSRF-Daten mitzusenden, statt den Schutz für Tests abzuschalten.
Je nach Anwendung kommen ungültige und abgelaufene Token, Abmeldung und mandantenfremde Zugriffe hinzu. Cookies, CORS, CSRF und Weiterleitungen sollten auch im Browser getestet werden: Symfonys Testclient setzt nicht alle Browserregeln durch.
Eine Anfrage systematisch untersuchen
Eine fehlgeschlagene Anfrage lässt sich in der folgenden Reihenfolge untersuchen. Dabei müssen Konfigurationsfakten und Vermutungen getrennt bleiben:
- Methode, vollständige URL, Status,
Content-Typeund Weiterleitungskette erfassen. Prüfen, ob die Antwort von Symfony oder einem vorgeschalteten Proxy stammt. - Die passende Firewall, den Sitzungskontext und
statelessbestimmen. Maßgeblich ist die wirksame Konfiguration der bereitgestellten Umgebung, nicht ein einzelner YAML-Ausschnitt. - Zuständige Authentifikatoren, Einstiegspunkt und Fehlerbehandlung prüfen. Feststellen, ob die erwarteten Zugangsdaten angekommen sind; Gültigkeit und Laden des Benutzers unter kontrollierten Bedingungen untersuchen.
- Die erkannte Identität, sofern vorhanden, die erste passende
access_control-Regel sowie zusätzliche Controller-, Voter- oder ausdrückliche Berechtigungsprüfungen ermitteln. - Browseranfrage und Next.js-Serveranfrage vergleichen: Ziel, Cookie-Geltungsbereich und bewusst weitergereichte Authentifizierungsdaten.
Die Security-Informationen des Symfony Profilers helfen in einer geschützten Entwicklungsumgebung. Der Profiler darf nicht öffentlich zugänglich sein. In Produktionsprotokolle gehören begrenzte Metadaten wie eine Korrelations-ID für die Anfrage, die Firewall, das Ergebnis und das Vorhandensein von Zugangsdaten — keine Passwörter, Tokenwerte, vollständigen Cookie-Header oder Sitzungsgeheimnisse. Auch exportierte Anfragemitschnitte müssen bereinigt werden.
Weiterführende Dokumentation
Versionsabhängige Einzelheiten sind mit den eingesetzten Abhängigkeiten abzugleichen:
- Symfony 7.4: Firewalls, Authentifizierung und Rollenhierarchie — https://symfony.com/doc/7.4/security.html
- Firewall-Kontext und stateless-Konfiguration — https://symfony.com/doc/7.4/reference/configuration/security.html
- Auswahl der Zugriffsregel — https://symfony.com/doc/7.4/security/access_control.html
- Eigene Authentifikatoren und Einstiegspunkte — https://symfony.com/doc/7.4/security/custom_authenticator.html und https://symfony.com/doc/7.4/security/entry_point.html
- Authentifizierung in Funktionstests — https://symfony.com/doc/7.4/testing.html#logging-in-users-authentication
- Browser-Cookies und CORS — https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie und https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- Weiterleitungen bei fetch erkennen und verhindern — https://developer.mozilla.org/en-US/docs/Web/API/Response/redirected
- Anfrageheader in Next.js — https://nextjs.org/docs/app/api-reference/functions/headers
- Schutz vor CSRF — https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- Bedeutung der HTTP-Statuscodes — https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes
Vor Änderungen an Anmeldung, Berechtigungen oder Frontend sollte die fehlgeschlagene HTTP-Anfrage durch den tatsächlich zuständigen Sicherheitskontext verfolgt werden.
