Custom Authentication ohne fertiges Bundle
Wer Login-Formulare, API-Schlüssel oder Signatur-Header authentifizieren muss, die kein Standard-Bundle abdeckt, kommt an einem eigenen Authenticator nicht vorbei. Mit AuthenticatorInterface, Passport und Badges lässt sich jede Authentifizierungslogik sauber, testbar und ohne Umwege über veraltete Guard-Klassen umsetzen.
Inhaltsverzeichnis
- 1. Warum ein eigener Authenticator nötig ist
- 2. Das AuthenticatorInterface im Detail
- 3. supports(): wann der Authenticator greift
- 4. authenticate() und der Passport
- 5. Badges: UserBadge, Credentials und eigene Marker
- 6. onAuthenticationSuccess und onAuthenticationFailure
- 7. Einen zweiten Faktor in den Authenticator einbauen
- 8. Funktionale Tests für den Authenticator schreiben
- 9. Eigener Authenticator im Vergleich zu Alternativen
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein eigener Authenticator nötig ist
Symfony liefert mit dem Formular-Login, dem JSON-Login und dem HTTP-Basic-Authenticator bereits Lösungen für die häufigsten Fälle. Sobald ein Projekt aber ein eigenes Token-Format, eine signierte Anfrage oder eine Kombination aus mehreren Nachweisen prüfen muss, reichen diese Standard-Authenticators nicht mehr aus. Genau hier setzt ein selbst geschriebener Authenticator an: er kapselt die komplette Prüf-Logik in einer einzigen Klasse, die dem Security-System über das AuthenticatorInterface bekannt gemacht wird.
Ein häufiges Missverständnis: viele Entwickler suchen zuerst nach den alten Guard-Klassen aus Symfony 3 und 4, die seit Symfony 5.3 vollständig durch das neue Authenticator-System ersetzt wurden. Ein eigener Authenticator nach dem neuen System ist deutlich schlanker, weil Passport und Badges die Verantwortlichkeiten klar trennen: Prüfung der Zugangsdaten, Laden des Benutzers und Reaktion auf Erfolg oder Fehlschlag sind voneinander unabhängige Schritte. Diese Trennung macht jeden Authenticator einzeln testbar, ohne den kompletten Security-Stack zu mocken.
2. Das AuthenticatorInterface im Detail
Jeder eigene Authenticator implementiert Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface, meist über die abstrakte Basisklasse AbstractAuthenticator. Vier Methoden bilden das Rückgrat: supports() entscheidet, ob dieser Authenticator für die aktuelle Anfrage zuständig ist, authenticate() baut das Passport-Objekt mit den Nachweisen, onAuthenticationSuccess() reagiert auf erfolgreiche Prüfung, onAuthenticationFailure() auf jede Art von Fehlschlag. Diese vier Methoden sind bei jedem Authenticator identisch strukturiert, egal ob er Formulare, API-Keys oder signierte Header prüft.
Der Vorteil dieser Struktur zeigt sich besonders in Projekten mit mehreren parallelen Authentifizierungswegen. Ein Authenticator für interne Mitarbeiter-Logins und ein zweiter für externe API-Clients können unabhängig voneinander in derselben Firewall registriert werden. Symfony fragt bei jeder Anfrage reihum jeden registrierten Authenticator, ob er zuständig ist, und beendet die Kette, sobald einer true zurückgibt. Damit lassen sich komplexe Security-Anforderungen ohne eine einzige monolithische Klasse abbilden.
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
/**
* Custom authenticator skeleton — every authenticator follows this shape.
*/
final class SignedRequestAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// Return null to skip silently, false to reject, true to handle
return $request->headers->has('X-Signature');
}
public function authenticate(Request $request): Passport
{
// Build and return a Passport with badges — see section 4
throw new \RuntimeException('Implemented in section 4');
}
public function onAuthenticationSuccess(Request $request, $token, string $firewallName): ?Response
{
return null; // let the request continue to the controller
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return null; // default JSON error response
}
}
3. supports(): wann der Authenticator greift
Die Methode supports() ist die Weiche jedes Authenticator-Aufrufs. Ein Rückgabewert von true bedeutet, dass genau dieser Authenticator die Anfrage bearbeitet, alle weiteren registrierten Authenticators werden übersprungen. false lehnt die Zuständigkeit aktiv ab. null ist der defensivste Rückgabewert: er signalisiert, dass der Authenticator die Anfrage weder ablehnt noch bearbeitet, wodurch Symfony einfach zum nächsten in der Kette übergeht, ohne einen Fehler zu erzeugen.
Ein typischer Fehler bei der Implementierung: supports() prüft zu wenig und akzeptiert Anfragen, die eigentlich für einen anderen Authenticator gedacht sind. Bei einem tokenbasierten Authenticator reicht die reine Existenz des Authorization-Headers nicht aus, wenn im selben Projekt auch Basic-Auth über denselben Header läuft. Ein Präfix-Check wie str_starts_with($header, 'Bearer ') macht die Zuständigkeit eindeutig und verhindert, dass zwei Authenticators um dieselbe Anfrage konkurrieren.
4. authenticate() und der Passport
In authenticate() entsteht das eigentliche Herzstück des Authenticator: das Passport-Objekt. Ein Passport bündelt einen UserBadge, der den Benutzer anhand eines Identifiers lädt, sowie beliebig viele weitere Badges für zusätzliche Prüfungen. Bei einem signierten Request extrahiert diese Methode zunächst die Signatur und die Nutzdaten aus dem Header, verifiziert die Signatur kryptografisch und wirft bei Fehlschlag sofort eine AuthenticationException, bevor überhaupt ein Benutzer geladen wird.
Wichtig ist, dass authenticate() selbst keine Antwort zurückgibt, sondern ausschließlich das Passport konstruiert. Die eigentliche Reaktion auf Erfolg oder Fehlschlag passiert getrennt in den beiden nachgelagerten Methoden. Diese Trennung ist kein Zufall: sie erlaubt es, denselben Authenticator in unterschiedlichen Firewalls mit unterschiedlichem Erfolgsverhalten wiederzuverwenden, ohne die Prüf-Logik zu duplizieren.
<?php
declare(strict_types=1);
namespace App\Security;
use App\Repository\ApiClientRepository;
use App\Security\Signature\SignatureVerifier;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
final class SignedRequestAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly ApiClientRepository $clients,
private readonly SignatureVerifier $verifier,
) {
}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-Signature')
&& $request->headers->has('X-Client-Id');
}
public function authenticate(Request $request): Passport
{
$clientId = $request->headers->get('X-Client-Id');
$signature = $request->headers->get('X-Signature');
$payload = $request->getContent();
// Load the shared secret before verifying — fails fast on unknown clients
$client = $this->clients->findByClientId($clientId);
if (null === $client) {
throw new CustomUserMessageAuthenticationException('Unknown client id.');
}
if (!$this->verifier->isValid($payload, $signature, $client->getSecret())) {
throw new CustomUserMessageAuthenticationException('Invalid request signature.');
}
// SelfValidatingPassport: no separate credentials badge needed,
// the signature check above already proves the identity
return new SelfValidatingPassport(
new UserBadge($clientId, fn (string $id) => $this->clients->findByClientId($id))
);
}
}
5. Badges: UserBadge, Credentials und eigene Marker
Badges sind das Erweiterungssystem des Passports und damit auch des Authenticator-Systems selbst. Der UserBadge ist praktisch immer Pflicht, weil er festlegt, wie der Benutzer anhand eines Identifiers geladen wird. Für klassische Passwort-Logins kommt PasswordCredentials hinzu, das den Vergleich mit dem gehashten Passwort übernimmt. Wenn die eigene Prüf-Logik, wie im Beispiel oben, die Identität bereits vollständig bestätigt hat, entfällt eine separate Credentials-Prüfung, und ein SelfValidatingPassport genügt.
Eigene Badge-Klassen erweitern das System um projektspezifische Prüfungen, ohne bestehende Authenticators anzufassen. Ein IpWhitelistBadge etwa kann in einem eigenen Badge-Checker prüfen, ob die anfragende IP-Adresse auf einer erlaubten Liste steht, und die Authentifizierung ablehnen, falls nicht. Solche Badges werden über einen EventListener auf CheckPassportEvent validiert, komplett getrennt vom eigentlichen Authenticator-Code. Dieses Muster hält den Authenticator selbst schlank und macht Zusatzprüfungen über mehrere Authenticators hinweg wiederverwendbar.
6. onAuthenticationSuccess und onAuthenticationFailure
Nach erfolgreicher Prüfung entscheidet onAuthenticationSuccess(), was mit der Anfrage passiert. Ein API-Authenticator gibt hier meist null zurück, damit die Anfrage normal beim Controller ankommt, während ein klassischer Login-Authenticator auf eine bestimmte Route umleitet. Der zweite Parameter, das erzeugte Token, enthält bereits den geladenen Benutzer und alle Rollen, ist also die zentrale Stelle, um zum Beispiel einen erfolgreichen Login zu protokollieren.
onAuthenticationFailure() übernimmt jede Art von Fehlschlag, egal ob durch eine explizit geworfene Exception oder durch ein fehlgeschlagenes Badge. Für APIs empfiehlt sich hier eine konsistente JSON-Antwort mit Statuscode 401, statt der Standard-HTML-Fehlerseite. Wichtig für jeden produktiv eingesetzten Authenticator: die Fehlermeldung darf keine internen Details preisgeben, etwa ob ein Client existiert aber die Signatur falsch ist, im Gegensatz zu einem komplett unbekannten Client. Eine einheitliche generische Meldung verhindert User-Enumeration.
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
// Add these two methods to the authenticator from section 4
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Continue to the controller — nothing to redirect for an API authenticator
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
// Generic message — never leak whether the client id exists
return new JsonResponse(
['error' => 'authentication_failed', 'message' => 'Request signature could not be verified.'],
Response::HTTP_UNAUTHORIZED
);
}
7. Einen zweiten Faktor in den Authenticator einbauen
Ein eigener Authenticator lässt sich problemlos um einen zweiten Faktor erweitern, ohne den gesamten Login-Fluss neu zu bauen. Der übliche Ansatz: nach erfolgreicher Passwortprüfung setzt onAuthenticationSuccess() statt eines vollständig authentifizierten Tokens einen Zwischenzustand in der Session, etwa pending_2fa, und leitet auf eine TOTP-Eingabeseite um. Erst ein zweiter, dedizierter Authenticator für den TOTP-Code stellt das eigentliche vollständige Token aus.
Diese Zwei-Stufen-Architektur hat einen entscheidenden Vorteil gegenüber einer Lösung, die beide Faktoren in einem einzigen Authenticator prüft: jeder Schritt bleibt unabhängig testbar, und der zweite Faktor kann pro Benutzer optional aktiviert werden, indem der erste Authenticator einfach direkt ein vollständiges Token ausstellt, wenn 2FA für diesen Account deaktiviert ist. Für zeitbasierte Codes eignet sich die Bibliothek spomky-labs/otphp, die RFC 6238 korrekt implementiert und sich sauber in einen eigenen Badge-Checker einbinden lässt.
8. Funktionale Tests für den Authenticator schreiben
Ein Authenticator ohne funktionale Tests ist ein Risiko, das bei jedem Symfony-Minor-Update erneut manuell geprüft werden müsste. Der pragmatischste Ansatz nutzt WebTestCase und schickt echte HTTP-Requests mit unterschiedlichen Header-Kombinationen gegen eine geschützte Test-Route. So wird nicht nur die Logik in authenticate() getestet, sondern der komplette Zusammenspiel aus Firewall-Konfiguration, Authenticator und Access-Control-Regeln.
Wichtige Testfälle für jeden Authenticator: gültige Zugangsdaten führen zu 200, fehlende Zugangsdaten zu 401 statt zu einem 500er, eine falsche Signatur zu 401 mit generischer Fehlermeldung, und ein unbekannter Client-Identifier ebenfalls zu 401 mit identischer Meldung wie bei falscher Signatur. Genau dieser letzte Test verhindert, dass sich unbemerkt eine User-Enumeration-Lücke einschleicht, weil die beiden Fehlerfälle im Code unterschiedlich behandelt werden, aber identisch nach außen antworten müssen.
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class SignedRequestAuthenticatorTest extends WebTestCase
{
public function testValidSignatureGrantsAccess(): void
{
$client = static::createClient();
$payload = '{"amount":100}';
$secret = 'test-secret';
$client->request(
'POST',
'/api/orders',
server: [
'HTTP_X-Client-Id' => 'client-42',
'HTTP_X-Signature' => hash_hmac('sha256', $payload, $secret),
],
content: $payload,
);
self::assertResponseIsSuccessful();
}
public function testInvalidSignatureReturnsGenericError(): void
{
$client = static::createClient();
$client->request('POST', '/api/orders', server: [
'HTTP_X-Client-Id' => 'client-42',
'HTTP_X-Signature' => 'not-a-valid-signature',
], content: '{"amount":100}');
self::assertResponseStatusCodeSame(401);
self::assertJsonStringEqualsJsonString(
'{"error":"authentication_failed","message":"Request signature could not be verified."}',
$client->getResponse()->getContent()
);
}
public function testUnknownClientReturnsSameGenericError(): void
{
$client = static::createClient();
$client->request('POST', '/api/orders', server: [
'HTTP_X-Client-Id' => 'does-not-exist',
'HTTP_X-Signature' => 'anything',
], content: '{}');
self::assertResponseStatusCodeSame(401);
// Must match the invalid-signature response exactly — no enumeration
}
}
Damit dieser Authenticator überhaupt zum Einsatz kommt, muss er in der Firewall-Konfiguration registriert werden. Symfony erkennt die Klasse dank Autowiring automatisch als Service, die security.yaml muss lediglich den vollqualifizierten Klassennamen in custom_authenticators eintragen, mehrere Authenticators lassen sich dabei problemlos in derselben Firewall kombinieren.
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\SignedRequestAuthenticator
# A second authenticator can be registered here too —
# Symfony asks each one in turn via supports()
access_control:
- { path: ^/api, roles: PUBLIC_ACCESS }
9. Eigener Authenticator im Vergleich zu Alternativen
Bevor ein eigener Authenticator geschrieben wird, lohnt sich der Blick auf die Alternativen. Nicht jedes Authentifizierungsproblem rechtfertigt eine komplett eigene Implementierung, manche Anforderungen deckt Symfony bereits mit Bordmitteln ab, andere lösen etablierte Bundles zuverlässiger und mit weniger Wartungsaufwand.
| Anforderung | Lösung | Aufwand | Empfehlung |
|---|---|---|---|
| Standard-Formular-Login | form_login-Authenticator |
Sehr gering | Kein eigener Authenticator nötig |
| Signierte Requests, HMAC | Eigener Authenticator | Mittel | Eigener Authenticator sinnvoll |
| Vollständiges OAuth2/OIDC | league/oauth2-server-bundle |
Hoch bei Eigenbau | Kein eigener Authenticator, Bundle nutzen |
| JWT-Bearer-Token | Eigener Authenticator oder lexik/jwt-authentication-bundle |
Gering bis mittel | Bundle für Standardfall, Eigenbau bei Sonderlogik |
| Mandantenspezifische Prüfregeln | Eigener Authenticator mit Badges | Mittel | Eigener Authenticator sinnvoll |
Die Faustregel lautet: sobald die Prüf-Logik projektspezifisch ist und kein etabliertes Protokoll wie OAuth2 abbildet, ist ein eigener Authenticator die richtige Wahl. Sobald ein Standardprotokoll im Spiel ist, überwiegt der Aufwand für Eigenbau die Vorteile fast immer, weil Sicherheitsdetails wie Token-Rotation oder Scope-Handling in etablierten Bundles bereits gegen reale Angriffe gehärtet wurden.
Mironsoft
Symfony Security, Authentication und Backend-Architektur
Individuelle Authentifizierung, sauber und testbar umgesetzt?
Wir bauen eigene Authenticators für Symfony, die Ihre spezifischen Sicherheitsanforderungen abbilden, von signierten Requests bis zu mehrstufigen Prüfungen, inklusive vollständiger funktionaler Tests.
Security-Audit
Bestehende Authenticators auf Lücken und Enumeration-Risiken prüfen
Custom Authenticator
Signierte Requests, API-Keys oder mehrstufige Prüfungen umsetzen
Test-Coverage
Funktionale Tests für alle Erfolgs- und Fehlerpfade aufbauen
10. Zusammenfassung
Ein eigener Authenticator in Symfony besteht aus vier klar getrennten Verantwortlichkeiten: supports() entscheidet über Zuständigkeit, authenticate() baut das Passport mit Badges, onAuthenticationSuccess() und onAuthenticationFailure() reagieren auf das Ergebnis. Diese Trennung macht jeden Authenticator unabhängig testbar und erlaubt es, mehrere Authentifizierungswege parallel zu betreiben, ohne eine monolithische Klasse zu pflegen.
Badges wie UserBadge und SelfValidatingPassport decken die meisten Fälle ab, eigene Badge-Checker erweitern das System um projektspezifische Prüfungen wie IP-Whitelisting oder Mehrfaktor-Zwischenschritte. Wo ein Standardprotokoll wie OAuth2 im Spiel ist, überwiegt fast immer ein etabliertes Bundle gegenüber Eigenbau. Funktionale Tests, die explizit auf identische Fehlermeldungen bei unterschiedlichen internen Fehlerursachen prüfen, sind der entscheidende Schutz gegen User-Enumeration in jedem produktiv eingesetzten Authenticator.
Eigener Symfony Authenticator — Das Wichtigste auf einen Blick
Vier Kernmethoden
supports(), authenticate(), onAuthenticationSuccess(), onAuthenticationFailure() bilden jeden Authenticator, unabhängig von der geprüften Nachweisart.
Passport und Badges
UserBadge lädt den Benutzer, weitere Badges prüfen Credentials oder eigene Regeln, SelfValidatingPassport spart überflüssige Prüfschritte.
Enumeration vermeiden
Unbekannter Client und falsche Signatur müssen identische Fehlermeldungen liefern, sonst entsteht eine testbare Informationslücke.
Testabdeckung
WebTestCase mit echten HTTP-Requests deckt Firewall, Authenticator und Access-Control gemeinsam ab, nicht nur isolierte Unit-Tests.