Custom authentication without a ready made bundle
Anyone who needs to authenticate login forms, API keys or signed headers that no standard bundle covers cannot avoid writing a custom authenticator. With AuthenticatorInterface, Passport and Badges, any authentication logic can be implemented cleanly and testably, without detouring through the outdated Guard classes.
Table of Contents
- 1. Why a custom authenticator becomes necessary
- 2. AuthenticatorInterface in detail
- 3. supports(): when the authenticator takes over
- 4. authenticate() and the Passport
- 5. Badges: UserBadge, credentials and custom markers
- 6. onAuthenticationSuccess and onAuthenticationFailure
- 7. Adding a second factor to the authenticator
- 8. Writing functional tests for the authenticator
- 9. Custom authenticator compared to alternatives
- 10. Summary
- 11. FAQ
1. Why a custom authenticator becomes necessary
Symfony already ships with solutions for the most common cases: the form login, the JSON login and the HTTP basic authenticator. But as soon as a project has to verify its own token format, a signed request or a combination of several proofs, these standard authenticators are no longer enough. That is exactly where a hand written authenticator comes in: it encapsulates the entire verification logic in a single class that the security system learns about through the AuthenticatorInterface.
A common misunderstanding: many developers first search for the old Guard classes from Symfony 3 and 4, which have been fully replaced by the new authenticator system since Symfony 5.3. A custom authenticator built on the new system is considerably leaner, because Passport and Badges cleanly separate responsibilities: checking credentials, loading the user and reacting to success or failure are independent steps. This separation makes every authenticator individually testable, without mocking the entire security stack.
2. AuthenticatorInterface in detail
Every custom authenticator implements Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface, usually through the abstract base class AbstractAuthenticator. Four methods form the backbone: supports() decides whether this authenticator is responsible for the current request, authenticate() builds the Passport object with the proofs, onAuthenticationSuccess() reacts to a successful check, and onAuthenticationFailure() reacts to any kind of failure. These four methods are structured identically for every authenticator, whether it checks forms, API keys or signed headers.
The advantage of this structure shows especially in projects with several parallel authentication paths. An authenticator for internal staff logins and a second one for external API clients can be registered independently within the same firewall. Symfony asks every registered authenticator in turn on every request whether it is responsible, and stops the chain as soon as one returns true. That makes it possible to model complex security requirements without a single monolithic class.
<?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(): when the authenticator takes over
The supports() method is the switch for every authenticator call. A return value of true means that exactly this authenticator handles the request, all other registered authenticators are skipped. false actively rejects responsibility. null is the most defensive return value: it signals that the authenticator neither rejects nor handles the request, which lets Symfony simply move on to the next one in the chain without producing an error.
A typical mistake when implementing this: supports() checks too little and accepts requests that were actually meant for a different authenticator. For a token based authenticator, the mere presence of the Authorization header is not enough if basic auth also runs over the same header in the same project. A prefix check like str_starts_with($header, 'Bearer ') makes responsibility unambiguous and prevents two authenticators from competing for the same request.
4. authenticate() and the Passport
In authenticate(), the actual heart of the authenticator emerges: the Passport object. A passport bundles a UserBadge, which loads the user by an identifier, plus any number of additional badges for further checks. For a signed request, this method first extracts the signature and payload from the header, cryptographically verifies the signature, and immediately throws an AuthenticationException on failure, before a user is even loaded.
It is important that authenticate() itself does not return a response, it only constructs the passport. The actual reaction to success or failure happens separately in the two subsequent methods. This separation is not accidental: it allows the same authenticator to be reused across different firewalls with different success behavior, without duplicating the verification logic.
<?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 and custom markers
Badges are the extension system of the Passport and thereby of the authenticator system itself. The UserBadge is practically always mandatory, because it defines how the user is loaded by an identifier. For classic password logins, PasswordCredentials is added, which handles the comparison against the hashed password. When custom verification logic, as in the example above, has already fully confirmed identity, a separate credentials check is unnecessary, and a SelfValidatingPassport is enough.
Custom badge classes extend the system with project specific checks without touching existing authenticators. An IpWhitelistBadge, for example, can check in its own badge checker whether the requesting IP address is on an allowed list, and reject authentication if not. Such badges are validated through an EventListener on CheckPassportEvent, completely separate from the actual authenticator code. This pattern keeps the authenticator itself lean and makes additional checks reusable across multiple authenticators.
6. onAuthenticationSuccess and onAuthenticationFailure
After a successful check, onAuthenticationSuccess() decides what happens to the request. An API authenticator usually returns null here, so the request reaches the controller normally, while a classic login authenticator redirects to a specific route. The second parameter, the created token, already contains the loaded user and all roles, making it the central place to log, for example, a successful login.
onAuthenticationFailure() handles every kind of failure, whether from an explicitly thrown exception or a failed badge. For APIs, a consistent JSON response with status code 401 is recommended here, instead of the default HTML error page. Important for every authenticator used in production: the error message must not leak internal details, such as whether a client exists but the signature is wrong, as opposed to a completely unknown client. A uniform generic message prevents 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. Adding a second factor to the authenticator
A custom authenticator can easily be extended with a second factor without rebuilding the entire login flow. The usual approach: after a successful password check, onAuthenticationSuccess() sets an intermediate state in the session instead of a fully authenticated token, for example pending_2fa, and redirects to a TOTP input page. Only a second, dedicated authenticator for the TOTP code issues the actual full token.
This two stage architecture has a decisive advantage over a solution that checks both factors in a single authenticator: every step stays independently testable, and the second factor can be enabled per user optionally, since the first authenticator simply issues a full token directly when 2FA is disabled for that account. For time based codes, the library spomky-labs/otphp is a good fit, correctly implementing RFC 6238 and integrating cleanly into a custom badge checker.
8. Writing functional tests for the authenticator
An authenticator without functional tests is a risk that would have to be checked manually again with every Symfony minor update. The most pragmatic approach uses WebTestCase and sends real HTTP requests with different header combinations against a protected test route. This tests not only the logic inside authenticate(), but the full interplay of firewall configuration, authenticator and access control rules.
Important test cases for every authenticator: valid credentials lead to 200, missing credentials lead to 401 instead of a 500, a wrong signature leads to 401 with a generic error message, and an unknown client identifier also leads to 401 with an identical message to the wrong signature case. Exactly this last test prevents a user enumeration gap from sneaking in unnoticed because the two failure cases are handled differently in code but must respond identically to the outside.
<?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
}
}
For this authenticator to actually run, it must be registered in the firewall configuration. Symfony recognizes the class automatically as a service thanks to autowiring, security.yaml only needs to list the fully qualified class name under custom_authenticators, and several authenticators can easily be combined within the same firewall.
# 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. Custom authenticator compared to alternatives
Before writing a custom authenticator, it is worth looking at the alternatives. Not every authentication problem justifies a fully custom implementation, some requirements are already covered by Symfony out of the box, others are solved more reliably by established bundles with less maintenance effort.
| Requirement | Solution | Effort | Recommendation |
|---|---|---|---|
| Standard form login | form_login authenticator |
Very low | No custom authenticator needed |
| Signed requests, HMAC | Custom authenticator | Medium | Custom authenticator makes sense |
| Full OAuth2/OIDC | league/oauth2-server-bundle |
High if built in house | No custom authenticator, use a bundle |
| JWT bearer token | Custom authenticator or lexik/jwt-authentication-bundle |
Low to medium | Bundle for the standard case, custom for special logic |
| Tenant specific verification rules | Custom authenticator with badges | Medium | Custom authenticator makes sense |
The rule of thumb is: as soon as the verification logic is project specific and does not map an established protocol like OAuth2, a custom authenticator is the right choice. As soon as a standard protocol is involved, the effort of building it in house almost always outweighs the benefits, because security details like token rotation or scope handling have already been hardened against real attacks in established bundles.
Mironsoft
Symfony Security, authentication and backend architecture
Custom authentication, implemented cleanly and testably?
We build custom authenticators for Symfony that map your specific security requirements, from signed requests to multi stage checks, including complete functional tests.
Security audit
Check existing authenticators for gaps and enumeration risks
Custom authenticator
Implement signed requests, API keys or multi stage checks
Test coverage
Build functional tests for every success and failure path
10. Summary
A custom authenticator in Symfony consists of four clearly separated responsibilities: supports() decides on responsibility, authenticate() builds the passport with badges, onAuthenticationSuccess() and onAuthenticationFailure() react to the result. This separation makes every authenticator independently testable and allows several authentication paths to run in parallel, without maintaining a monolithic class.
Badges like UserBadge and SelfValidatingPassport cover most cases, custom badge checkers extend the system with project specific checks such as IP whitelisting or multi factor intermediate steps. Where a standard protocol like OAuth2 is involved, an established bundle almost always outweighs building it in house. Functional tests that explicitly check for identical error messages across different internal failure causes are the decisive protection against user enumeration in every authenticator used in production.
Custom Symfony Authenticator — The Key Points at a Glance
Four core methods
supports(), authenticate(), onAuthenticationSuccess(), onAuthenticationFailure() form every authenticator, regardless of the kind of proof being checked.
Passport and badges
UserBadge loads the user, further badges verify credentials or custom rules, SelfValidatingPassport skips unnecessary verification steps.
Avoiding enumeration
An unknown client and a wrong signature must return identical error messages, otherwise a testable information gap emerges.
Test coverage
WebTestCase with real HTTP requests covers firewall, authenticator and access control together, not just isolated unit tests.