Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

API Token Authentication

API Token Authentication

~15 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026

form_login from chapter 27 fits browser users – for a later API extension of our task manager (e.g. a mobile app, EXACTLY as in our separate Magento REST API tutorials), a DIFFERENT authentication path is needed: stateless API tokens.

Why form_login doesn't fit APIs

form_login is based on SESSIONS (chapter 12) – the server remembers the logged-in state server-side, the browser only sends along a session cookie. An API client (mobile app, script, another server) OFTEN has no practical way to manage cookies – a bearer token in the Authorization header (EXACTLY as we use it in the Magento REST API tutorials) is the standard approach for APIs.

Adding an apiToken field to User

#[ORM\Column(length: 255, nullable: true, unique: true)]
private ?string $apiToken = null;

public function getApiToken(): ?string
{
    return $this->apiToken;
}

public function setApiToken(?string $apiToken): static
{
    $this->apiToken = $apiToken;

    return $this;
}
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Building a custom authenticator

src/Security/ApiTokenAuthenticator.php
<?php

declare(strict_types=1);

namespace App\Security;

use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
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;

class ApiTokenAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly UserRepository $userRepository,
    ) {
    }

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('Authorization')
            && str_starts_with($request->headers->get('Authorization'), 'Bearer ');
    }

    public function authenticate(Request $request): Passport
    {
        $authHeader = $request->headers->get('Authorization');
        $apiToken = substr($authHeader, 7); // strip 'Bearer '

        if ($apiToken === '') {
            throw new CustomUserMessageAuthenticationException('No API token provided.');
        }

        return new SelfValidatingPassport(
            new UserBadge($apiToken, function (string $apiToken) {
                $user = $this->userRepository->findOneBy(['apiToken' => $apiToken]);

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

                return $user;
            })
        );
    }

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

supports() checks whether THIS authenticator is even responsible (a bearer token is present) – authenticate() loads the matching user. SelfValidatingPassport (instead of a Passport with a separate password badge like with form_login) signals: the token IS already complete proof of identity, NO additional password check needed.

A separate firewall for API routes

config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - App\Security\ApiTokenAuthenticator
        main:
            # ... as in chapter 27, for the browser area ...

    access_control:
        - { path: ^/api, roles: ROLE_USER }
        - { path: ^/login, roles: PUBLIC_ACCESS }
        - { path: ^/projects, roles: ROLE_USER }

stateless: true is DECISIVE: forbids Symfony from creating a session for this firewall – EVERY request must send its token AGAIN, EXACTLY how a real API should work. pattern: ^/api ensures ONLY routes under /api/... use this authenticator, while /projects keeps running through form_login (the main firewall).

Achtung: MULTIPLE firewalls get checked TOP to BOTTOM, the FIRST whose pattern matches wins – the more specific api firewall MUST therefore come BEFORE the more general main firewall, otherwise /api/... would wrongly be handled by main.

Testing the API token

TOKEN="abc123..."
curl -H "Authorization: Bearer $TOKEN" https://aufgaben-manager.local/api/projects

Tipp: The exact same structure (bearer token in the Authorization header) as in our separate React/React Native & Magento tutorials – the same principle reappears in ALMOST every modern API, regardless of the backend framework used.