API Token Authentifizierung in Symfony: Muster für sichere APIs
AI generated
SF
{ }
Symfony · API Security · Token · PHP
API Token Authentifizierung in Symfony
Muster für sichere, wartbare APIs

Ein API-Token im Klartext in der Datenbank ist eine tickende Zeitbombe. Dieser Leitfaden zeigt das vollständige Muster für API Token Authentifizierung in Symfony, von der sicheren Generierung über gehashte Speicherung bis zu Rotation, Scopes und einem funktionierenden Widerrufsmechanismus.

18 Min. Lesezeit Hashing · Rotation · Scopes · Rate-Limiting Symfony 6.4 · 7.x · PHP 8.2+

1. Warum API Token Authentifizierung ein eigenes Muster braucht

Ein API-Client meldet sich nicht mit Passwort und Session an, sondern mit einem einzigen langlebigen Geheimnis, dem API-Token. Diese API Token Authentifizierung unterscheidet sich fundamental vom Formular-Login: es gibt keine Session, jede Anfrage muss eigenständig authentifizierbar sein, und ein kompromittiertes Token bleibt oft monatelang gültig, wenn keine Rotation vorgesehen ist. Wer diese Unterschiede ignoriert und einfach ein Passwort-Feld für Tokens wiederverwendet, öffnet Angriffsflächen, die bei klassischen Logins gar nicht existieren.

Das Ziel eines robusten Musters für API Token Authentifizierung ist, dass ein gestohlenes Token so wenig Schaden wie möglich anrichtet, und dass ein Diebstahl überhaupt bemerkt werden kann. Das bedeutet konkret: Tokens werden nie im Klartext gespeichert, sie haben ein Ablaufdatum, sie tragen Scopes, die ihre Berechtigungen einschränken, und sie lassen sich einzeln widerrufen, ohne alle anderen Tokens des Kunden ungültig zu machen. Die folgenden Abschnitte bauen dieses Muster Schritt für Schritt auf.

2. Token-Formate im Vergleich: Opaque, JWT und mehr

Für API Token Authentifizierung stehen im Wesentlichen zwei Formatfamilien zur Wahl. Ein opaker Token ist eine zufällige Zeichenkette ohne eigene Struktur, die serverseitig gegen eine Datenbank aufgelöst wird. Ein JWT hingegen trägt seine Claims direkt im Token, ist selbstbeschreibend und kann ohne Datenbankzugriff verifiziert werden, solange die Signatur stimmt. Für die meisten internen APIs ist ein opaker Token die pragmatischere Wahl, weil er sich sofort und ohne Blacklist widerrufen lässt, ein Vorteil, den JWTs strukturell nicht bieten.

JWTs spielen ihre Stärke aus, wenn Verifikation ohne zentrale Datenbank stattfinden muss, etwa bei verteilten Microservices, die den Token nur mit einem öffentlichen Schlüssel prüfen. Der Nachteil: ein einmal ausgestelltes JWT bleibt bis zum Ablauf gültig, ein sofortiger Widerruf erfordert zusätzliche Infrastruktur wie eine Revocation-Liste. Für die klassische API Token Authentifizierung mit direktem Datenbankzugriff, wie sie die meisten Symfony-Backends betreiben, ist ein opaker Token mit Datenbank-Lookup meist die robustere und einfachere Lösung.

3. Token sicher speichern: Hashing statt Klartext

Der häufigste Fehler bei API Token Authentifizierung: das Token wird als Klartext in einer Spalte gespeichert, damit es bei einem Support-Fall einfach nachgeschlagen werden kann. Genau das ist das Problem, denn ein Datenbank-Leak macht dann jeden aktiven Token sofort nutzbar. Die korrekte Lösung: nur ein Hash des Tokens landet in der Datenbank, der Klartext existiert ausschließlich für den kurzen Moment der Ausstellung und wird dem Client genau einmal angezeigt.

Für das Hashing selbst genügt bei zufällig generierten Tokens mit ausreichender Entropie ein schneller kryptografischer Hash wie SHA-256, ein teurer Passwort-Hash wie password_hash() mit Bcrypt ist hier nicht nötig, weil ein Angreifer keinen Brute-Force gegen ein 256-Bit-Zufallstoken führen kann. Wichtig ist zusätzlich ein kurzer, unhashed Präfix in der Datenbank, damit ein Token beim Support schnell identifiziert werden kann, ohne den vollständigen Klartext preiszugeben.


<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'api_token')]
#[ORM\Index(columns: ['token_hash'], name: 'idx_token_hash')]
class ApiToken
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid', unique: true)]
    private Uuid $id;

    // Short, unhashed prefix — safe to show in the admin UI for support
    #[ORM\Column(length: 12)]
    private string $prefix;

    // SHA-256 hash of the full token — never store the plaintext
    #[ORM\Column(length: 64, unique: true)]
    private string $tokenHash;

    #[ORM\Column]
    private array $scopes = [];

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $expiresAt = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $revokedAt = null;

    #[ORM\ManyToOne(targetEntity: ApiClient::class)]
    private ApiClient $client;

    public function isExpired(): bool
    {
        return $this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable();
    }

    public function isRevoked(): bool
    {
        return $this->revokedAt !== null;
    }
}

4. Token-Generierung als Console-Command

Die Ausstellung eines neuen Tokens für API Token Authentifizierung gehört in einen dedizierten Command oder Service, niemals direkt in einen Controller, damit sowohl Admin-Backend als auch CLI dieselbe Logik nutzen. Der Ablauf: ein kryptografisch sicherer Zufallswert wird über random_bytes() erzeugt, in Base62 oder Hex kodiert, der Hash wird berechnet und gespeichert, der Klartext wird genau einmal zurückgegeben.

Ein häufiger Fehler an dieser Stelle: uniqid() oder ein einfacher Zeitstempel als Basis für den Token. Beides ist vorhersehbar und für API Token Authentifizierung ungeeignet, weil die Entropie viel zu gering ist. random_bytes(32) liefert 256 Bit kryptografisch sichere Zufälligkeit, ausreichend gegen jeden realistischen Brute-Force-Versuch, selbst bei sehr hoher Anfragerate.


<?php

declare(strict_types=1);

namespace App\Command;

use App\Entity\ApiToken;
use App\Repository\ApiClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Uid\Uuid;

#[AsCommand(name: 'app:api-token:create', description: 'Issue a new API token for a client')]
final class CreateApiTokenCommand extends Command
{
    public function __construct(
        private readonly ApiClientRepository $clients,
        private readonly EntityManagerInterface $em,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('client-id', InputArgument::REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $client = $this->clients->findByClientId($input->getArgument('client-id'));

        // 256 bits of cryptographically secure randomness — never uniqid()
        $plaintext = bin2hex(random_bytes(32));

        $token = new ApiToken();
        $token->setPrefix(substr($plaintext, 0, 8));
        $token->setTokenHash(hash('sha256', $plaintext));
        $token->setClient($client);
        $token->setExpiresAt(new \DateTimeImmutable('+90 days'));

        $this->em->persist($token);
        $this->em->flush();

        // Shown exactly once — never retrievable again after this point
        $output->writeln("Token: {$plaintext}");
        return Command::SUCCESS;
    }
}

5. Eigenen API-Token-Authenticator implementieren

Der Authenticator für API Token Authentifizierung extrahiert das Token aus dem Authorization-Header, hasht den empfangenen Wert mit demselben Algorithmus wie bei der Ausstellung und sucht den Hash in der Datenbank. Ein zeitkonstanter Vergleich ist bei einem Datenbank-Lookup über einen Index nicht zwingend erforderlich, weil die Antwortzeit ohnehin durch die Datenbankabfrage dominiert wird, anders als bei einem direkten String-Vergleich im Anwendungscode.

Direkt nach dem Auffinden des Tokens müssen drei Zustände geprüft werden: abgelaufen, widerrufen und der zugehörige Client aktiv. Alle drei Fälle führen zur selben generischen Fehlermeldung, damit ein Angreifer aus der Antwort nicht ableiten kann, ob das Token existierte, aber abgelaufen war, oder ob es nie existiert hat. Diese Konsistenz ist für jede API Token Authentifizierung genauso wichtig wie bei Passwort-basierten Authenticators.


<?php

declare(strict_types=1);

namespace App\Security;

use App\Repository\ApiTokenRepository;
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 ApiTokenAuthenticator extends AbstractAuthenticator
{
    public function __construct(private readonly ApiTokenRepository $tokens)
    {
    }

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

    public function authenticate(Request $request): Passport
    {
        $plaintext = substr($request->headers->get('Authorization'), 7);
        $hash = hash('sha256', $plaintext);

        $token = $this->tokens->findOneByHash($hash);

        // Same generic message for "not found", "expired" and "revoked"
        if ($token === null || $token->isExpired() || $token->isRevoked()) {
            throw new CustomUserMessageAuthenticationException('Invalid or expired API token.');
        }

        return new SelfValidatingPassport(
            new UserBadge($token->getClient()->getClientId(), fn () => $token->getClient())
        );
    }
}

6. Token-Rotation und Ablaufzeiten

Ein Token ohne Ablaufdatum ist bei API Token Authentifizierung ein dauerhaftes Risiko, weil ein einziger Leak Jahre später noch nutzbar sein kann. Kurze Lebenszeiten von 30 bis 90 Tagen begrenzen den Schaden automatisch, erfordern aber einen funktionierenden Rotationsmechanismus, damit Clients nicht plötzlich mit einem abgelaufenen Token dastehen. Das übliche Muster: ein Refresh-Endpunkt akzeptiert das noch gültige alte Token kurz vor Ablauf und stellt ein neues aus, während das alte für eine Übergangsfrist von wenigen Minuten parallel gültig bleibt.

Diese Übergangsfrist ist wichtig, weil ein Client bei laufenden Requests sonst mitten in der Rotation scheitern könnte. Ein zweites, unabhängiges Muster für API Token Authentifizierung mit hohen Sicherheitsanforderungen: sehr kurzlebige Access-Tokens von wenigen Minuten kombiniert mit einem langlebigeren, aber strikt eingeschränkten Refresh-Token, das ausschließlich für die Ausstellung neuer Access-Tokens verwendet werden darf und selbst niemals direkt für API-Zugriffe akzeptiert wird.


<?php

declare(strict_types=1);

namespace App\Controller;

use App\Repository\ApiTokenRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class RefreshApiTokenController extends AbstractController
{
    #[Route('/api/token/refresh', name: 'app_api_token_refresh', methods: ['POST'])]
    public function __invoke(ApiTokenRepository $tokens): JsonResponse
    {
        $currentToken = $this->getUser(); // resolved by the existing ApiTokenAuthenticator

        // Old token stays valid for a short grace period so in-flight requests don't fail
        $newPlaintext = bin2hex(random_bytes(32));
        $tokens->issueSuccessor($currentToken, hash('sha256', $newPlaintext), graceMinutes: 5);

        return $this->json(['token' => $newPlaintext, 'expires_in' => 7776000]);
    }
}

7. Scopes und granulare Rechte pro Token

Ein einzelner Token, der volle Rechte auf die gesamte API gewährt, widerspricht dem Prinzip der geringsten Berechtigung. API Token Authentifizierung mit Scopes löst das: jeder Token trägt eine Liste erlaubter Aktionen, etwa orders:read oder orders:write, und der Authenticator oder ein nachgelagerter Voter prüft vor jeder Aktion, ob der aktuelle Token den nötigen Scope besitzt. Ein reines Lese-Token für ein Reporting-Tool kann so niemals versehentlich Bestellungen verändern, selbst wenn der Client-Code fehlerhaft ist.

Die Scope-Prüfung selbst gehört nicht in den Authenticator, sondern in einen eigenen Security Voter oder in ein Attribut auf dem Controller, damit die Zuständigkeiten sauber getrennt bleiben. Der Authenticator ist ausschließlich für die Identität zuständig, während die Autorisierung, also welche Scopes für welche Aktion nötig sind, in der Business-Schicht entschieden wird. Diese Trennung macht Scope-Regeln änderbar, ohne die API Token Authentifizierung selbst anzufassen.

8. Widerruf und Rate-Limiting kombinieren

Ein zentraler Vorteil des opaken Tokens gegenüber JWT: der Widerruf ist ein einzelnes UPDATE-Statement, das revokedAt setzt, sofort wirksam bei der nächsten Anfrage. Für API Token Authentifizierung in sicherheitskritischen Systemen sollte jeder Widerruf zusätzlich protokolliert werden, inklusive Zeitpunkt und auslösendem Administrator, um im Nachhinein nachvollziehen zu können, warum ein Token deaktiviert wurde.

Rate-Limiting pro Token, nicht pro IP-Adresse, verhindert, dass ein einzelner kompromittierter oder fehlerhaft konfigurierter Client die gesamte API überlastet. Symfonys RateLimiter-Komponente lässt sich direkt mit dem Token-Identifier als Limiter-Key kombinieren, sodass jeder Token ein eigenes Kontingent erhält, unabhängig von anderen Clients, die möglicherweise dieselbe IP-Adresse teilen, etwa hinter einem gemeinsamen Firmen-Proxy.


# config/packages/rate_limiter.yaml
framework:
  rate_limiter:
    api_token:
      policy: 'token_bucket'
      limit: 100
      rate: { interval: '1 minute', amount: 100 }
      # The limiter key is set at call time to the API token identifier,
      # not the client IP — see the controller below

9. API Token Muster im Vergleich

Nicht jedes Projekt braucht die volle Ausbaustufe aus Hashing, Rotation, Scopes und Rate-Limiting von Anfang an. Die folgende Übersicht ordnet die Muster nach Sicherheitsniveau und Implementierungsaufwand ein, damit die Entscheidung zum tatsächlichen Risiko des Projekts passt.

Muster Sicherheitsgewinn Aufwand Empfehlung
Klartext-Token in DB Keiner Minimal Nie verwenden
Gehashter Token ohne Ablauf Mittel Gering Minimum für Produktion
Gehasht + Ablauf + Rotation Hoch Mittel Empfohlener Standard
+ Scopes pro Token Sehr hoch Mittel bis hoch Für Multi-Tenant-APIs Pflicht
+ Rate-Limiting pro Token Sehr hoch Mittel Für öffentliche APIs Pflicht

Für interne Tools reicht oft die zweite Stufe. Für Multi-Tenant-Produkte oder öffentlich zugängliche APIs ist die vollständige API Token Authentifizierung mit Rotation, Scopes und Rate-Limiting kein Luxus, sondern die Voraussetzung, um überhaupt auf reale Angriffe reagieren zu können, ohne alle Clients gleichzeitig auszusperren.

Mironsoft

API-Sicherheit, Token-Architektur und Symfony-Backend

Eine API, deren Tokens wirklich sicher sind?

Wir bauen API Token Authentifizierung für Symfony-Projekte, von gehashter Speicherung über Rotation und Scopes bis zu Rate-Limiting pro Client, produktionsreif und mit vollständiger Testabdeckung.

Token-Audit

Bestehende Token-Speicherung auf Klartext-Risiken prüfen

Rotation & Scopes

Ablauf, Rotation und granulare Scopes pro Token einführen

Rate-Limiting

Kontingente pro Token statt pro IP-Adresse einrichten

10. Zusammenfassung

API Token Authentifizierung ist mehr als eine Zufallszeichenkette in einer Spalte. Ein produktionsreifes Muster speichert nur den Hash, nie den Klartext, versieht jeden Token mit einem Ablaufdatum und einem funktionierenden Rotationsmechanismus, und beschränkt Rechte über Scopes statt über einen einzigen allmächtigen Token. Ein eigener Authenticator, der Token gegen die gehashte Datenbank prüft und bei jedem Fehlerfall dieselbe generische Meldung liefert, bildet die technische Basis dieses Musters.

Widerruf und Rate-Limiting pro Token runden das Muster ab: ein kompromittiertes Token lässt sich sofort deaktivieren, ohne andere Clients zu beeinträchtigen, und ein fehlerhaft konfigurierter Client kann nicht die gesamte API überlasten. Für interne Tools genügt oft eine reduzierte Ausbaustufe, für öffentliche APIs und Multi-Tenant-Produkte ist die vollständige API Token Authentifizierung mit allen fünf Bausteinen die einzige verantwortbare Basis.

API Token Authentifizierung — Das Wichtigste auf einen Blick

Hashing statt Klartext

Nur der SHA-256-Hash landet in der Datenbank, der Klartext wird dem Client genau einmal bei der Ausstellung gezeigt.

Rotation und Ablauf

Kurze Lebenszeiten von 30 bis 90 Tagen begrenzen den Schaden eines Leaks, ein Refresh-Endpunkt sichert reibungslose Erneuerung.

Scopes statt Alles-Token

Jeder Token trägt eine Liste erlaubter Aktionen, geprüft in einem separaten Voter, nicht im Authenticator selbst.

Widerruf und Rate-Limiting

Ein UPDATE-Statement widerruft sofort, Rate-Limiting pro Token schützt vor fehlerhaften oder kompromittierten Clients.

11. FAQ: API Token Authentifizierung in Symfony

1Tokens mit Bcrypt hashen?
Nein, bei 256 Bit Zufallsentropie reicht SHA-256. Bcrypt ist für niedrig-entropische Passwörter gedacht.
2Opak vs. JWT beim Widerruf?
Opak wird bei jeder Anfrage geprüft, Widerruf sofort wirksam. JWT bleibt bis Ablauf gültig ohne zusätzliche Revocation-Liste.
3Wie lange sollte ein Token gelten?
30 bis 90 Tage üblich. Sicherheitskritische Systeme kombinieren kurzlebige Access- mit längerlebigem Refresh-Token.
4Was sind Scopes?
Eine Liste erlaubter Aktionen pro Token, etwa orders:read, geprüft vor jeder Aktion durch einen Voter.
5Wo gehört die Scope-Prüfung hin?
In einen separaten Voter oder Controller-Attribut, nicht in den Authenticator, der nur für Identität zuständig ist.
6Ein Token widerrufen?
Ein UPDATE setzt revokedAt, der Authenticator lehnt es ab der nächsten Anfrage ab, andere Tokens bleiben unberührt.
7Rate-Limiting pro Token oder IP?
Pro Token, weil mehrere Clients dieselbe IP teilen können, etwa hinter einem Firmen-Proxy.
8Was speichere ich für den Support?
Einen kurzen unhashed Präfix, ausreichend zur Identifikation, ohne den Klartext wiederherstellbar zu machen.
9Anfrage während der Rotation?
Eine kurze Übergangsfrist hält altes und neues Token parallel gültig, damit laufende Requests nicht scheitern.
10Reicht random_bytes() aus?
Ja, 32 Bytes ergeben 256 Bit kryptografisch sichere Entropie, ausreichend gegen Brute-Force.