Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

API tokens

A mobile app or another service does not log in through a form or keep a session. It sends a token — a long random string — with every request. In Symfony the access_token setting turns on token checking.

A separate firewall

An API and a site are protected differently, so the /api addresses get a section of their own:

# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            access_token:
                token_handler: App\Security\AccessTokenHandler
        main:
            # the regular form login
 
    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api, roles: ROLE_USER }

stateless: true turns the session off: every request is checked afresh.

The token entity

#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
class AccessToken
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;
 
    #[ORM\Column(length: 64, unique: true)]
    private string $hash;
 
    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;
 
    #[ORM\Column]
    private \DateTimeImmutable $expiresAt;
 
    public function isValid(): bool
    {
        return $this->expiresAt > new \DateTimeImmutable();
    }
 
    // the constructor and the get methods
}

The database stores not the token itself but its hash.

The token handler

namespace App\Security;
 
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
 
class AccessTokenHandler implements AccessTokenHandlerInterface
{
    public function __construct(private AccessTokenRepository $tokens) {}
 
    public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
    {
        $token = $this->tokens->findOneBy(['hash' => hash('sha256', $accessToken)]);
 
        if (!$token || !$token->isValid()) {
            throw new BadCredentialsException('Invalid token');
        }
 
        return new UserBadge($token->getUser()->getUserIdentifier());
    }
}

Symfony takes the token out of the Authorization: Bearer … header by itself and passes it to the method.

Issuing a token

#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(
    #[MapRequestPayload] LoginInput $input,
    UserRepository $users,
    UserPasswordHasherInterface $hasher,
    EntityManagerInterface $em,
): JsonResponse {
    $user = $users->findOneBy(['email' => $input->email]);
 
    if (!$user || !$hasher->isPasswordValid($user, $input->password)) {
        return $this->json(['error' => 'Wrong email or password'], 401);
    }
 
    $plain = bin2hex(random_bytes(32));
 
    $em->persist(new AccessToken(hash('sha256', $plain), $user, new \DateTimeImmutable('+30 days')));
    $em->flush();
 
    return $this->json(['token' => $plain]);
}

The current user

use Symfony\Component\Security\Http\Attribute\CurrentUser;
 
#[Route('/api/me', name: 'api_me')]
public function me(#[CurrentUser] User $user): JsonResponse
{
    return $this->json(['email' => $user->getEmail()]);
}

A client request

curl https://example.com/api/me -H "Authorization: Bearer 3f9a1c..."

Without a token, or with an expired one, the response is a 401 code.

JWT

Another widespread kind of token is JWT: the user data is kept in the token itself and signed with a key, so the database need not be queried on every request. The lexik/jwt-authentication-bundle package adds it. The downside: an issued JWT cannot be revoked before it expires.

Common mistake

A token stored in the database in plain form is the same as a plain password: a database leak hands over access to every account. Store a hash and show the token itself to the client once. And do not pass a token in the request address: addresses end up in server logs and browser history.

Thanks for reading!

Donate to the site — we work hard for you.