API Token Authentication in Symfony: Patterns for Secure APIs
AI generated
SF
{ }
Symfony · API Security · Token · PHP
API Token Authentication in Symfony
Patterns for secure, maintainable APIs

An API token stored in plaintext in the database is a ticking time bomb. This guide shows the complete pattern for API token authentication in Symfony, from secure generation through hashed storage to rotation, scopes and a working revocation mechanism.

18 min read Hashing · Rotation · Scopes · Rate Limiting Symfony 6.4 · 7.x · PHP 8.2+

1. Why API token authentication needs its own pattern

An API client does not log in with a password and session, it presents a single long lived secret, the API token. This API token authentication differs fundamentally from a form login: there is no session, every request must be independently authenticatable, and a compromised token often stays valid for months if no rotation is in place. Anyone who ignores these differences and simply reuses a password field for tokens opens attack surfaces that do not even exist for classic logins.

The goal of a robust pattern for API token authentication is that a stolen token causes as little damage as possible, and that a theft can be noticed at all. Concretely that means tokens are never stored in plaintext, they carry an expiration date, they carry scopes that restrict their permissions, and they can be revoked individually without invalidating all of a customer's other tokens. The following sections build this pattern step by step.

2. Token formats compared: opaque, JWT and more

For API token authentication, essentially two format families are available. An opaque token is a random string with no structure of its own, resolved server side against a database. A JWT, on the other hand, carries its claims directly in the token, is self describing and can be verified without database access, as long as the signature checks out. For most internal APIs, an opaque token is the more pragmatic choice, because it can be revoked immediately and without a blacklist, an advantage JWTs structurally do not offer.

JWTs show their strength when verification has to happen without a central database, for example in distributed microservices that only check the token with a public key. The downside: a JWT once issued stays valid until expiration, immediate revocation requires additional infrastructure such as a revocation list. For classic API token authentication with direct database access, as most Symfony backends operate, an opaque token with a database lookup is usually the more robust and simpler solution.

3. Storing tokens securely: hashing instead of plaintext

The most common mistake in API token authentication: the token is stored as plaintext in a column so it can be easily looked up for a support case. That is exactly the problem, because a database leak then makes every active token immediately usable. The correct solution: only a hash of the token ends up in the database, the plaintext exists only for the brief moment of issuance and is shown to the client exactly once.

For the hashing itself, a fast cryptographic hash like SHA-256 is enough for randomly generated tokens with sufficient entropy, an expensive password hash like password_hash() with Bcrypt is not needed here, because an attacker cannot brute force a 256 bit random token. What is additionally important is a short, unhashed prefix in the database, so a token can be quickly identified during support without revealing the full plaintext.


<?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 generation as a console command

Issuing a new token for API token authentication belongs in a dedicated command or service, never directly in a controller, so that both the admin backend and the CLI use the same logic. The flow: a cryptographically secure random value is generated via random_bytes(), encoded in Base62 or hex, the hash is computed and stored, the plaintext is returned exactly once.

A common mistake here: using uniqid() or a simple timestamp as the basis for the token. Both are predictable and unsuitable for API token authentication, because the entropy is far too low. random_bytes(32) delivers 256 bits of cryptographically secure randomness, sufficient against any realistic brute force attempt, even at a very high request rate.


<?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. Implementing a custom API token authenticator

The authenticator for API token authentication extracts the token from the Authorization header, hashes the received value with the same algorithm used at issuance, and looks up the hash in the database. A constant time comparison is not strictly required for a database lookup over an indexed column, because the response time is already dominated by the database query, unlike a direct string comparison in application code.

Right after finding the token, three states must be checked: expired, revoked, and the associated client active. All three cases lead to the same generic error message, so an attacker cannot deduce from the response whether the token existed but was expired, or never existed at all. This consistency matters just as much for every API token authentication scheme as it does for password based 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 and expiration

A token without an expiration date is a permanent risk in API token authentication, because a single leak can remain usable years later. Short lifetimes of 30 to 90 days automatically limit the damage, but require a working rotation mechanism so clients do not suddenly find themselves with an expired token. The usual pattern: a refresh endpoint accepts the still valid old token shortly before expiration and issues a new one, while the old one remains valid in parallel for a transition period of a few minutes.

This transition period matters because a client could otherwise fail mid rotation on requests already in flight. A second, independent pattern for API token authentication with high security requirements: very short lived access tokens of a few minutes combined with a longer lived, strictly restricted refresh token, which may only be used to issue new access tokens and is itself never accepted directly for API access.


<?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 and granular permissions per token

A single token that grants full rights across the entire API contradicts the principle of least privilege. API token authentication with scopes solves this: every token carries a list of allowed actions, for example orders:read or orders:write, and the authenticator or a downstream voter checks before every action whether the current token has the required scope. A pure read only token for a reporting tool can then never accidentally modify orders, even if the client code is buggy.

The scope check itself does not belong in the authenticator, but in a dedicated security voter or an attribute on the controller, so that responsibilities stay cleanly separated. The authenticator is exclusively responsible for identity, while authorization, meaning which scopes are needed for which action, is decided in the business layer. This separation makes scope rules changeable without touching API token authentication itself.

8. Combining revocation and rate limiting

A key advantage of the opaque token over JWT: revocation is a single UPDATE statement that sets revokedAt, effective immediately on the next request. For API token authentication in security critical systems, every revocation should additionally be logged, including the time and the triggering administrator, so it can later be traced why a token was deactivated.

Rate limiting per token, not per IP address, prevents a single compromised or misconfigured client from overloading the entire API. Symfony's RateLimiter component can be combined directly with the token identifier as the limiter key, so every token gets its own quota, independent of other clients that might share the same IP address, for example behind a shared corporate 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 patterns compared

Not every project needs the full expansion of hashing, rotation, scopes and rate limiting from the start. The following overview ranks the patterns by security level and implementation effort, so the decision matches the actual risk of the project.

Pattern Security gain Effort Recommendation
Plaintext token in the DB None Minimal Never use
Hashed token without expiration Medium Low Minimum for production
Hashed + expiration + rotation High Medium Recommended standard
+ scopes per token Very high Medium to high Mandatory for multi tenant APIs
+ rate limiting per token Very high Medium Mandatory for public APIs

For internal tools, the second stage is often enough. For multi tenant products or publicly accessible APIs, full API token authentication with rotation, scopes and rate limiting is not a luxury, it is the prerequisite for being able to react to real attacks at all, without locking out every client simultaneously.

Mironsoft

API security, token architecture and Symfony backend

An API whose tokens are actually secure?

We build API token authentication for Symfony projects, from hashed storage through rotation and scopes to per client rate limiting, production ready and with complete test coverage.

Token audit

Check existing token storage for plaintext risks

Rotation & scopes

Introduce expiration, rotation and granular scopes per token

Rate limiting

Set up quotas per token instead of per IP address

10. Summary

API token authentication is more than a random string in a column. A production ready pattern stores only the hash, never the plaintext, gives every token an expiration date and a working rotation mechanism, and restricts permissions through scopes instead of a single all powerful token. A custom authenticator that checks tokens against the hashed database and returns the same generic message on every failure forms the technical foundation of this pattern.

Revocation and rate limiting per token round off the pattern: a compromised token can be deactivated immediately without affecting other clients, and a misconfigured client cannot overload the entire API. For internal tools, a reduced expansion is often enough, for public APIs and multi tenant products, full API token authentication with all five building blocks is the only defensible baseline.

API Token Authentication — The Key Points at a Glance

Hashing instead of plaintext

Only the SHA-256 hash ends up in the database, the plaintext is shown to the client exactly once at issuance.

Rotation and expiration

Short lifetimes of 30 to 90 days limit the damage of a leak, a refresh endpoint secures smooth renewal.

Scopes instead of an all token

Every token carries a list of allowed actions, checked in a separate voter, not in the authenticator itself.

Revocation and rate limiting

An UPDATE statement revokes immediately, rate limiting per token protects against faulty or compromised clients.

11. FAQ: API Token Authentication in Symfony

1Hash tokens with Bcrypt?
No, SHA-256 is enough for 256 bit random entropy. Bcrypt is designed for low entropy passwords.
2Opaque vs. JWT for revocation?
Opaque is checked on every request, revocation immediate. JWT stays valid until expiration without an extra revocation list.
3How long should a token last?
30 to 90 days is common. Security critical systems combine short lived access with longer lived refresh tokens.
4What are scopes?
A list of allowed actions per token, for example orders:read, checked before every action by a voter.
5Where does the scope check belong?
In a separate voter or controller attribute, not in the authenticator, which is only responsible for identity.
6Revoke a token?
An UPDATE sets revokedAt, the authenticator rejects it from the next request on, other tokens stay untouched.
7Rate limit per token or IP?
Per token, because several clients can share the same IP, for example behind a corporate proxy.
8What to store for support?
A short unhashed prefix, enough for identification, without making the plaintext recoverable.
9Request during rotation?
A short transition period keeps old and new token valid in parallel, so requests in flight do not fail.
10Is random_bytes() enough?
Yes, 32 bytes yield 256 bits of cryptographically secure entropy, sufficient against brute force.