In this post I describe a simple way of generating a simple JWT based API only with the help of the default symfony tools. Ok, two exception – I will use doctrine/dbal to access the database and a JWT library to generate and validate the JWT.
At the end you will have a login endpoint where you send your credentials to. From there you will get back a JWT token you can use for the following requests to the API.

This blog post was written while using symfony 5.2.

Prerequirenments

  • Of course you need PHP installed. I use PHP 8 in this guide.
  • You need composer. I would advice to use version 2. You can download it here if it is not already installed.
  • What will also help is the symfony command to setup and run the app. How to do that can be found here.
  • If there are PHP modules missing, normally composer will tell it, if you try installing a package or bundle which requires it.
  • You need a MySQL server. I use version 8.
  • Postman for testing the API.

How this guide is constructed

This guide is separated in several steps which rely on each other:

  1. Preparations
    1. Create the app skeleton
    2. Adjust environment for local development
    3. Database setup
  2. Create the login part
  3. Create the secured API part

Preparation

Create the app skeleton

First of all let’s setup a new application with

symfony new jwt-test

This command will create a new directory jwt-test in your current directory and generates the app skeleton inside.

Switch to your new project directory and install the needed doctrine bundles.

composer require doctrine/doctrine-bundle
composer require doctrine/doctrine-migrations-bundle

Now everything is installed and we can continue with coding.

Adjust .env file for local development

In your project directory you will find a .env file. Copy it and place it at the same place with the name .env.local. Do not commit this file in the repository if you are working with a VCS (normally you should, to keep track of your changes in the code).

Database setup

To continue you have to have somewhere a MySQL server running. I won’t describe the setup of MySQL here.
You need your user credentials, database name and hostname of your database server.
With these data you can now adjust the DATABASE_URL setting in your .env.local file.

DATABASE_URL="mysql://<username>:<password>@<hostname>:3306/<database>?serverVersion=8.0&charset=utf8"

In the above config I used a MySQL server with version 8.x. If you use a different version you have to set the correct version there, e.g. 5.7.
Also the charset is set to utf8. If you use something different, adjust it, too. E.g. as default charset I used uft8mb4 and as default collation utf8mb4_unicode_ci.

Let’s create a migration for the users table where the API users are saved:

bin/console doctrine:migrations:generate

This will generate a new migration file where will add our new users table. It will show you the file name with its path where is is saved.
Now you have to open it with your editor and add the sqls for your user table there.

// ...
    public function getDescription(): string
    {
        return 'Add users table';
    }

    public function up(Schema $schema): void
    {
        $this->addSql(
            <<<'SQL'
CREATE TABLE users (
	id varchar(50) NOT NULL,
	name varchar(100) NOT NULL,
	password varchar(100) NOT NULL,
	email varchar(255) NOT NULL,
	roles varchar(255) NOT NULL,
	CONSTRAINT users_pk PRIMARY KEY (id)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
CREATE INDEX users_name_IDX USING BTREE ON users (name);
CREATE UNIQUE INDEX users_email_IDX USING BTREE ON users (email);
SQL
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP TABLE IF EXISTS users;');
    }
// ...

Save the file and execute the migration with:

bin/console doctrine:migrations:migrate

Now you will have the new table in your database.

For testing our API and especially for the first step the login, you have to add a new user to the database.
You can add with your preferred tool a new entry into the newly generated table. As id I use Uuids here. You can generate one for your new user here. The password can be generated with the symfony command:

bin/console security:encode-password <password>

Create the login part

In this section you will create the login part.

Install the required packages/bundles

First of all you have to install the JWT package and the symfony security-bundle.

composer require lcobucci/jwt
composer require symfony/security-bundle

Let’s code some stuff

Now the interesting part begings.

Some notes at the beginning:Structure your code! This will make the things easy to be maintained. Keep the code simple – keep your classes simple! Try to create a separate class for each part of your application – do not put too much logic into one class or one method. This will make it easier to test your code.

What we need for the login part?

First of all:
Don’t be confused. In the following part in the code many times $username will be used. But here we identify the user not by its username but by its email. Because of that, later you will use the email as username when you log in.
Another thing: When I write something like <username> or <password>, this is meant as placeholder and you have to replace everything with your data. E.g. <username> will be replaced with something like user1 etc. I did that for example above where the DATABASE_URL was adjusted.

So, what do we need:

  • A user class where the data of the user is save
  • A user provider which will read your user from the database
  • Two handlers which will be used on a successful login and on a failed login
  • A class for generating the JWT
  • A class to handle the database access

Let’s structure the code

All your code will be placed below the src directory! Only the migrations will be placed outside, but that will handle the doctrine migration bundle anyway for you if you did not change anything on its config.
So, create a Security directory below src. In this directory all your “security” related stuff will be placed. Inside the Security directory you have to create a Jwt directory. In this directory the JWT related classes will be placed later.

Now add the classes

App\Security\ApiUser:

<?php
declare(strict_types=1);

namespace App\Security;

use Symfony\Component\Security\Core\User\UserInterface;

/**
 * Class ApiUser
 */
class ApiUser implements UserInterface
{

    private string $id;
    private string $name;
    private string $email;
    private string $password;
    private string|null $apiToken = null;
    private array $roles = [];

    // TODO:
    // Add all getters and setters.
    // I normally use fluent setters to make setting the fields easier.

    // the required methods from the UserInterface
    public function getSalt(): string
    {
        // we do not use a salt
        return '';
    }

    public function getUsername(): string
    {
        // return the email field, because we identify the user by email
        return $this->email;
    }

    public function eraseCredentials(): void
    {
        // clear the password and the apiToken field
        $this->password = '';
        $this->apiToken = '';
    }
}

App\Security\ApiUserProvider:

<?php
declare(strict_types=1);

namespace App\Security;

use App\Repository\UserRepository;
use Doctrine\DBAL\Exception as DBALException;
use Exception;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use function explode;
use function is_subclass_of;
use function sprintf;

/**
 * Class ApiUserProvider
 */
class ApiUserProvider implements UserProviderInterface
{

    public function __construct(private UserRepository $userRepository)
    {
    }

    /**
     * @throws DBALException
     */
    public function loadUserByUsername(string $username): ApiUser
    {
        $user = $this->userRepository->fetchUserByEmail($username);

        if (!$user) {
            throw new UsernameNotFoundException();
        }

        $apiUser = new ApiUser();
        $apiUser->setId($user['id'])
            ->setName($user['name'])
            ->setEmail($user['email'])
            ->setPassword($user['password'])
            ->setRoles(explode(',', $user['roles']));

        return $apiUser;
    }

    /**
     * @param UserInterface $user
     *
     * @throws Exception
     */
    public function refreshUser(UserInterface $user)
    {
        // we have a stateless service, this method will never be called
        throw new Exception(sprintf('Method %s not supported.', __METHOD__));
    }

    public function supportsClass(string $class): bool
    {
        return ApiUser::class === $class || is_subclass_of($class, ApiUser::class);
    }
}

App\Security\LoginFailureHandler:

<?php
declare(strict_types=1);

namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;

/**
 * Class LoginSuccessHandler
 *
 * @package App\Security
 */
class LoginFailureHandler implements AuthenticationFailureHandlerInterface
{
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
    {
        return new JsonResponse(
            [
                'error' => 'INVALID_CREDENTIALS',
            ],
            Response::HTTP_BAD_REQUEST
        );
    }
}

App\Security\LoginSuccessHandler:

<?php
declare(strict_types=1);

namespace App\Security;

use App\Security\Jwt\JwtBuilder;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;

/**
 * Class LoginSuccessHandler
 *
 * @package App\Security
 */
class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{

    public function __construct(private JwtBuilder $jwtBuilder)
    {
    }

    /**
     * @inheritDoc
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        $user = $token->getUser();
        if (!$user instanceof ApiUser) {
            throw new AuthenticationServiceException('Invalid user object.');
        }

        $user->setApiToken($this->jwtBuilder->generateToken($user));

        return new JsonResponse(['token' => $user->getApiToken()]);
    }
}

App\Security\Jwt\JwtBuilder:

<?php
declare(strict_types=1);

namespace App\Security\Jwt;

use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * Class JwtBuilder
 */
class JwtBuilder
{
    private Configuration $configuration;

    public function __construct(string $secretKey)
    {
        $this->configuration = Configuration::forSymmetricSigner(
            new Sha256(),
            InMemory::base64Encoded($secretKey)
        );
    }

    public function generateToken(UserInterface $user): string
    {
        $now = new DateTimeImmutable();
        $token = $this->configuration->builder()
            // Configures the issuer (iss claim)
            ->issuedBy($_SERVER['HTTP_HOST'])
            // Configures the audience (aud claim)
            ->permittedFor($_SERVER['HTTP_HOST'])
            // Configures the id (jti claim)
            ->identifiedBy($user->getUsername())
            // Configures the time that the token was issue (iat claim)
            ->issuedAt($now)
            // Configures the time that the token can be used (nbf claim)
            ->canOnlyBeUsedAfter($now)
            // Configures the expiration time of the token (exp claim)
            ->expiresAt($now->modify('+1 hour'))
            // Configures a new claim, called "uid"
            ->withClaim('username', $user->getUsername())
            ->withClaim('roles', $user->getRoles())
            // Builds a new token
            ->getToken($this->configuration->signer(), $this->configuration->signingKey());

        return $token->toString();
    }
}

App\Repository\AbstractRepository:

<?php
declare(strict_types=1);

namespace App\Repository;

use Doctrine\DBAL\Connection;

/**
 * Class AbstractRepository
 */
class AbstractRepository
{
    public function __construct(protected Connection $connection)
    {
    }
}

App\Repository\UserRepository:

<?php
declare(strict_types=1);

namespace App\Repository;

use Doctrine\DBAL\Exception;
use Doctrine\DBAL\ParameterType;

/**
 * Class UserRepository
 */
class UserRepository extends AbstractRepository
{
    /**
     * @param string $email
     *
     * @return array|null
     * @throws Exception
     */
    public function fetchUserByEmail(string $email): ?array
    {
        $user = $this->connection->fetchAssociative(
            'SELECT * FROM users WHERE email = :email;',
            [
                'email' => $email,
            ],
            [
                'email' => ParameterType::STRING,
            ]
        );

        return $user === false ? null : $user;
    }
}

All classes are created. Now you only have to adjust some configuration to make everything running:

To handle the login the json_login endpoint will be used, which is shipped with symfony by default. Here you can simply send a JSON object in the format {"username":"<username>","password":"<password>"} to log in.
Now adjust your security config with the login handler, the user provider etc. For that open the /config/packages/security.yaml and adjust it:

security:
    encoders:
        App\Security\ApiUser:
            algorithm: bcrypt

    providers:
        api_user_provider:
            id: App\Security\ApiUserProvider

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        login:
            pattern: ^/api/login
            anonymous: true
            stateless: true
            lazy: true
            json_login:
                check_path: /api/login
                success_handler: App\Security\LoginSuccessHandler
                failure_handler: App\Security\LoginFailureHandler

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
         - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY \}

Define the route where the login will take place:

login:
    path: /api/login
    methods: [POST]

Adjust the autowire config to allow injecting the secret key we need to generate the JWT.
Only add the bind part with the $secretKey. Do not edit any other setting!

# ...
services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
        bind:
            $secretKey: '%env(SECRET_KEY)%'
# ...

And at last you’ll have to generate our secret key which will be used for generating the JWT. For that you need to have openssl installed. Simply run in terminal openssl rand -base64 32 and paste the output into your .env.local file at the end.
If you don’t have OpenSSL installed maybe this project may help.

# replace that string with your output from the command
SECRET_KEY=6mkgwkzaNyZwY3wDbJUgYvL3kyPcEkZob7hB+MctyB0=

You can also paste the SECRET_KEY part without the value into the .env to have it in the repository to not forget it later to adjust in another environment.

You are done. Now everything should work and you should be able to log in.

Test the login

To test everything run:

symfony serve

This will start a local webserver and will show how you can reach it. By default it will be https://127.0.0.1:8000, which will not work for me. I have to call https://localhost:8000.

You can now open Postman and send a POST request to https://localhost:8000/api/login with the body:

{
    "username": "<email of the user>",
    "password": "<password>"
}

On success you will get back the generated token and on failure you will get an error message.

{
    "token": "eyJ0eXAiOiJK..."
}

On failure you will get the error message.

{
    "error": "INVALID_CREDENTIALS"
}

Create the secured API part

To allow using the API with a JWT, you need an authenticator which will decode the JWT and extracts the user out of it. You have to validate the JWT, too, to be sure your API generated the JWT.

For parsing and validating the JWT a new class is added. It is called JwtValidator and will have on method called validate. The validate method will get the JWT and parses it and validates it. The class will look like the following:

<?php
declare(strict_types=1);

namespace App\Security\Jwt;

use Exception;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

/**
 * Class JwtValidator
 */
class JwtValidator
{
    private Configuration $configuration;

    public function __construct(string $secretKey)
    {
        $this->configuration = Configuration::forSymmetricSigner(
            new Sha256(),
            InMemory::base64Encoded($secretKey)
        );
        $this->configuration->setValidationConstraints(
            new SignedWith(new Sha256(), InMemory::base64Encoded($secretKey))
        );
    }

    public function validate(string $jwt): string
    {
        // first: parse the JWT
        try {
            $token = $this->configuration->parser()->parse($jwt);
        } catch (Exception) {
            throw new AuthenticationException('INVALID_JWT');
        }

        if (!$token instanceof UnencryptedToken) {
            throw new AuthenticationException('INVALID_JWT');
        }

        // second: validate the parsed JWT
        $constraints = $this->configuration->validationConstraints();

        if (!$this->configuration->validator()->validate($token, ...$constraints)) {
            throw new AuthenticationException('INVALID_JWT');
        }

        return $token->claims()->get('username');
    }
}

If the validation of the JWT token was successful the username stored in the JWT will be returned. If there is an error an Exception is thrown which has to be catched later in the authenticator. You don’t have to think about catching the thrown exception. Symfony will handle that by itself and will call a method in the authenticator where you could handle what will be displayed to the user.

In the above code, it will be only checked if the JWT token was generated with the secret key you defined in one of the previous steps. You can extend the validation with several constraints. You can have a look here to see which constraints are available. To add additional constraints simply add them to the setValidationConstraints() method in the constructor.

Let’s create the authenticator class and place it in src/Security:

<?php
declare(strict_types=1);

namespace App\Security;

use App\Security\Jwt\JwtValidator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

/**
 * Class ApiUserAuthenticator
 */
class ApiUserAuthenticator extends AbstractGuardAuthenticator
{

    public function __construct(private JwtValidator $jwtValidator)
    {
    }

    /**
     * Called on every request to decide if this authenticator should be
     * used for the request. Returning `false` will cause this authenticator
     * to be skipped.
     */
    public function supports(Request $request): bool
    {
        return $request->headers->has('X-AUTH-TOKEN');
    }

    /**
     * Called on every request. Return whatever credentials you want to
     * be passed to getUser() as $credentials.
     */
    public function getCredentials(Request $request)
    {
        return $request->headers->get('X-AUTH-TOKEN');
    }

    /**
     * @param mixed $credentials
     * @param UserProviderInterface $userProvider
     *
     * @return UserInterface|null
     */
    public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
    {
        if ($credentials === null) {
            // The token header was empty, authentication fails with HTTP Status
            // Code 401 "Unauthorized"
            return null;
        }

        // validate the JWT and get back the username (email)
        $username = $this->jwtValidator->validate($credentials);

        return $userProvider->loadUserByUsername($username);
    }

    /**
     * @param mixed $credentials
     * @param UserInterface $user
     *
     * @return bool
     */
    public function checkCredentials($credentials, UserInterface $user): bool
    {
        return true;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response
    {
        // on success, let the request continue
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            'error' => $exception->getMessage(),
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    /**
     * Called when authentication is needed, but it's not sent
     */
    public function start(Request $request, AuthenticationException $authException = null): Response
    {
        $data = [
            // you might translate this message
            'error' => 'AUTHENTICATION_REQUIRED',
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    public function supportsRememberMe(): bool
    {
        return false;
    }
}

This authenticator class will be used, if there is a field called X-AUTH-TOKEN in the header of the http request. If it is there the JwtValidator will be used to check if the JWT is valid. If its value is invalid somehow a JsonResponse will be returned which shows the error message.

Now that the JWT validator and authenticator is there, you need to enable it in the settings. For that you have to extend the security.yaml located in the config/packages directory.
You have to insert a second firewall admin_api which will use your added authenticator. You have to extend the access_control part, too, to allow access to the specific part only by admins:

  security:
[...]
    firewalls:
        dev:
[...]
        login:
[...]
        admin_api:
            pattern: ^/api/admin
            anonymous: true
            stateless: true
            lazy: true
            guard:
                authenticators:
                    - App\Security\ApiUserAuthenticator
[...]
    access_control:
         - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/admin, roles: ROLE_ADMIN }

After the firewall is configured, you have to create a controller, to test it.
To do that, you have to create a controller in src/Controller and add one method to it which will be called:

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Security\ApiUser;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

/**
 * Class ExampleController
 */
class ExampleController extends AbstractController
{
    public function example(): JsonResponse
    {
        $loggedInUser = $this->getUser();

        if (!$loggedInUser instanceof ApiUser) {
            return new JsonResponse('Invalid user class.', Response::HTTP_INTERNAL_SERVER_ERROR);
        }

        return new JsonResponse(
            [
                'user' => [
                    'id' => $loggedInUser->getId(),
                    'name' => $loggedInUser->getName(),
                    'email' => $loggedInUser->getEmail(),
                    'roles' => $loggedInUser->getRoles(),
                ],
            ]
        );
    }
}

In the above controller action we return a json object with the user data if the user is authenticated successfully.

To make the controller reachable add it to the routes.yaml:

[...]
protected_example:
    path: /api/admin/example
    controller: App\Controller\ExampleController::example
    methods: [GET]

To test it, call first the login endpoint you created earlier, to get a fresh JWT.

Now with Postman request the https://localhost:8000/api/admin/example and add the JWT into the X-AUTH-TOKEN header field.
On success you should see the JSON representation of your user data from the database.

{
    "user": {
        "id": "bfbbefbb-45f6-4761-b2f1-06092f7fd118 ",
        "name": "Admin",
        "email": "admin@example.com",
        "roles": [
            "ROLE_ADMIN"
        ]
    }
}

If there is any failure, you will get an error. E. g. you will see the following if the JWT is somehow invalid:

{
    "error": "INVALID_JWT"
}

Or you will see the following error if there is no X-AUTH-TOKEN at all:

{
    "error": "AUTHENTICATION_REQUIRED"
}

That’s it. You will now have a JWT based API with login functionality.

You can find the complete code on github: kostob/api-base (github.com)

Categories: Symfony
Tags: API JWT PHP symfony