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:migrateBuilding a custom authenticator
<?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
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/projectsTipp: 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.