Custom Authenticators

Writing an AbstractAuthenticator that returns a Passport of badges, and choosing the right response when it fails.

Syntaxpublic function authenticate(Request $request): Passport

An authenticator is the class a firewall asks: who sent this request? Symfony ships several, including form_login, json_login, access_token and login_link. You write your own when your scheme is none of those: a signed header from a mobile client, a vendor's token format, a shared secret between two of your own services.

Four methods

Extend AbstractAuthenticator and you implement four. supports() says whether this request is yours. authenticate() returns a Passport or throws. onAuthenticationSuccess() and onAuthenticationFailure() return the response, or null to let the request carry on. The base class supplies createToken(), which is the part you rarely need to touch.

The passport

A passport is a collection of badges: small objects each naming something that must hold true. UserBadge carries the identifier, and optionally a callable that loads the user when a provider cannot. Credentials go in PasswordCredentials or CustomCredentials. Further badges add checks: CsrfTokenBadge, RememberMeBadge, PasswordUpgradeBadge. Use SelfValidatingPassport when the request already proves identity and there is nothing left to verify.

Failing well

Throw any AuthenticationException from authenticate(). CustomUserMessageAuthenticationException is the one whose message is safe to show the user. In onAuthenticationFailure(), an HTML firewall redirects back to the login page; a stateless API firewall should answer 401 with a body, never a redirect to a page no client will read.

Register the class under custom_authenticators. It is autowired like any other service.

Example

Example · php
<?php
// src/Security/ApiTokenAuthenticator.php

declare(strict_types=1);

namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
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 ApiTokenAuthenticator extends AbstractAuthenticator
{
    public function __construct(private readonly ApiTokenRepository $tokens)
    {
    }

    public function supports(Request $request): ?bool
    {
        // false, not null: without the header this authenticator is irrelevant.
        return $request->headers->has('X-API-TOKEN');
    }

    public function authenticate(Request $request): Passport
    {
        $value = (string) $request->headers->get('X-API-TOKEN', '');

        // SelfValidatingPassport: holding a valid token is itself the proof,
        // so there are no credentials left to check.
        return new SelfValidatingPassport(
            new UserBadge($value, function (string $value): UserInterface {
                $token = $this->tokens->findOneUnexpiredByValue($value); // one query

                if (null === $token) {
                    throw new CustomUserMessageAuthenticationException('Invalid or expired API token.');
                }

                return $token->getOwner();
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // let the controller produce the response
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED);
    }
}

// GET /api/orders with a valid X-API-TOKEN header reaches the controller and
// gets its normal 200. With a missing or expired token the response is 401
// with {"error":"Invalid or expired API token."}.

When to use it

  • Authenticate mobile clients that send a signed token in a custom header.
  • Let two of your own services talk to each other using a secret from the vault.
  • Return 401 JSON for /api instead of a 302 to the HTML login page.
  • Add CSRF checking to a login endpoint you wrote by hand rather than with form_login.

More examples

Registering it on a stateless firewall

stateless: true is what makes this an API firewall rather than a website one. Nothing is written to a session, so there is no cookie and no login round trip, and the authenticator runs on every request. Listing several entries under custom_authenticators is allowed; Symfony offers the request to each in turn and the first whose supports() returns true handles it. Keep the api firewall above main, because main has no pattern and would otherwise claim /api first.

Example · yaml
# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true          # no session; every request carries its token
            provider: app_user_provider
            custom_authenticators:
                - App\Security\ApiTokenAuthenticator

        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login

    access_control:
        - { path: ^/api, roles: ROLE_API }

The built-in route, before writing a class

One method instead of four. The access_token firewall already knows how to pull a bearer token out of the Authorization header, a query string or the request body, and how to answer 401 when the handler rejects it. Your code only turns a token string into a UserBadge. Try this first for any bearer-token API, and write a full authenticator only when the credential does not arrive as a single extractable string, or when you need control over the success and failure responses.

Example · php
<?php
// src/Security/AccessTokenHandler.php

declare(strict_types=1);

namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

final class AccessTokenHandler implements AccessTokenHandlerInterface
{
    public function __construct(private readonly ApiTokenRepository $tokens)
    {
    }

    public function getUserBadgeFrom(string $accessToken): UserBadge
    {
        $token = $this->tokens->findOneUnexpiredByValue($accessToken);

        if (null === $token) {
            throw new BadCredentialsException('Invalid credentials.');
        }

        return new UserBadge($token->getOwner()->getUserIdentifier());
    }
}

// config/packages/security.yaml
//
// firewalls:
//     api:
//         pattern: ^/api
//         stateless: true
//         access_token:
//             token_handler: App\Security\AccessTokenHandler
//             token_extractors: ['header']   # Authorization: Bearer <token>

Badges, and the same badge written two ways

A full Passport as a password-checking authenticator builds it: a UserBadge with just the identifier, so the firewall's user provider does the loading; a PasswordCredentials holding the submitted plaintext, which the password hasher verifies against the stored hash; then badges for the extra checks. RememberMeBadge is created disabled, so enable() is needed unless the firewall's listener switches it on from always_remember_me or the _remember_me parameter. The parentheses around new became optional in PHP 8.4, and both forms appear in current code, including the Symfony documentation.

Example · php
<?php
// src/Security/HeaderLoginAuthenticator.php (the authenticate() method)

declare(strict_types=1);

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
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;

public function authenticate(Request $request): Passport
{
    // ->request is the POST body. ->query would be the query string.
    $email = (string) $request->request->get('_username', '');
    $password = (string) $request->request->get('_password', '');
    $csrfToken = (string) $request->request->get('_csrf_token', '');

    // From PHP 8.4 you can call a method directly on a new object.
    // These two lines are the same thing:
    $rememberMe = new RememberMeBadge()->enable();
    // $rememberMe = (new RememberMeBadge())->enable();

    return new Passport(
        new UserBadge($email),
        new PasswordCredentials($password),
        [
            new CsrfTokenBadge('authenticate', $csrfToken),
            $rememberMe,
        ],
    );
}

Discussion

  • Be the first to comment on this lesson.