Securing API With JWT In Symfony
Tags: php, symfony, jwt
Published: 2022-09-11 11:28:47


In APIs, user verification is often done by using generated keys which are returned in a response after a valid login process. One way of identifying registered users during the requests is by the JWT tokens, which are often sent in the request headers. Once the token is successfully decoded, the application is able to recognize the identity of a user.

In this post I'm going to show you how to easily create a user verification system based on the aforementioned tokens in Symfony 6.

## What is JWT

Json Web Token (JWT) is a standard (RFC 7519) which defines how to securely exchange data in JSON format. Token can be encoded with a secret key using HMAC algorithm. Asymmetric algorithms, like RSA or ECDSA, are also often used.

Token contains three parts which are separated by dots - `a.b.c`. The parts of the token are:
- Header - contains info about the algorithm which has been used to encrypt the data,
- Payload - this part contains data which we want to encode in the token - it can be, e.g. user id, their role in the system or the expiration date of the token,
- Signature - it's a digital signature which confirms that the data in the token has not been changed.

## JWT in Symfony

Symfony, with its components and a few external libraries, allows us to set up authentication and authorization in just a few simple steps. To secure our API we are going to use:

- SecurityBundle
- LexikJWTAuthenticationBundle

To begin with, let's install SecurityBundle:

```
composer require symfony/security-bundle
```

`LexikJWTAuthenticationBundle` will be used to handle log in, token generation and validation

```
composer require lexik/jwt-authentication-bundle
```

In this example, to encrypt the tokens, I'm going to use a pair of private/public keys. To generate them, we only need to run below command:

```
php bin/console lexik:jwt:generate-keypair
```

The keys will be generated in `config/jwt` directory.

The configuration of `LexikJWTAuthenticationBundle` is located in `config/packages/lexik_jwt_authentication.yaml`. By default, it contains paths to the keys and a passphrase, which are read from environment variables. We need to add these values to the `.env` file if they are missing, but usually they will be added automatically during installation of the bundle:

```yaml
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pub
JWT_PASSPHRASE=secretkey
```

Token is valid for one hour by default. It can be changed by setting `token_ttl` param to the given amount of seconds in the configuration file.

## Securing the application

`SecurityBundle` is a very powerful tool which allows us to configure applications' security. It provides several ways of controlling user access and gives us the ability to create own ones.

Below example shows how to configure in-memory user, with login and password stored in the configuration file. Configuring a user in this way is very simple and requires adding the following code to the `config/packages/security.yml` file

```yaml
security:
    password_hashers:
      Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    providers:
        my_in_memory_users:
            memory:
                users:
                    admin: { password: $2y$13$hA/l.ZYwcmAnxoZbfJtPdeFrWhrpmbnkYeWafMZR7vb1ilo5wOt5., roles: [ 'ROLE_ADMIN' ] }
```

In the `password_hashers` section, we choose a password hashing algorithm (`auto` selects the best available hasher). In the `providers` we declare a user and their password, which is being hashed by running the command:

```
bin/console security:hash-password
```

Once the user has been added, we have to determine which resources will be protected from unauthorized access. To handle it, we will use the next sections in the `security.yaml` file - `firewalls` and `access_control`

Application can have several secured areas, e.g. admin panel or API. We can use a different provider for each of them.
In this case, we are going to configure two values in the firewall section:

- authentication:
  ```yaml
  api_login:
      pattern: ^/api/login
      stateless: true
      json_login:
          provider: my_in_memory_users
          check_path: /api/login_check #same as the route configured in config/routes.yaml
          success_handler: lexik_jwt_authentication.handler.authentication_success
          failure_handler: lexik_jwt_authentication.handler.authentication_failure
  ```

  In the above configuration, in the `pattern` parameter we specify which resources are going to be protected by the firewall.

- resource for authenticated user:

  We add a new resource in the firewall, which will be accessible for logged-in users

  ```yaml
  api:
      pattern:   ^/api
      stateless: true
      jwt: ~
  ```

  Here we specify a pattern indicating which resource will be protected - in this case, all urls starting with `/api`. The `jwt` parameter gives us the control over the authentication process. We are going to use the default service provided by `LexikJWTAuthenticationBundle` - `JWTAuthenticator`. It decodes the token and authenticates it. We can also provide our own authenticator by creating a service which implements `AuthenticatorInterface`.

Now we have to add routing which was used in `api_login` section:

```yaml
# config/routes.yaml
api_login_check:
path: /api/login_check
```

In the `access_control` section we have to specify two values:

```yaml
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }
```

To test if logging in works as expected, we make a request to the login page:

```
curl -X POST -H "Content-Type: application/json" https://localhost/api/login_check -d '{"username":"admin","password":"admin1"}'
```

In the response we receive a token:

```json
{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1ODQyNzY1ODEsImV4cCI6MTU4NDI4MDE4MSwicm9sZXMiOlsiUk9MRV9BRE1JTiJdLCJ1c2VybmFtZSI6ImFkbWluIn0.u6VbYVV_fqSd9Y1_8wUPlzD20cET79EgnEsk19iqVG48db0kx9UFRIzjb62SyhdqPnfWXXsfXGapKS70XMHaxSaOpe7_P2f_bCAkiYJKgwGj6PhjDq8dFhx1AAXPKFWzWBj5mvjMaF0gCQEy00iNzoFhuqBEaK4fl6SqHsM4Nd1TigUsmNg1Kxrz4-G0W8Iz9vGiuRPjMVJzKYMh4iAemvIx8pB3XLYlmgvVvYQd5mMlxIhm4YsVTyXkwijSJPisK-RhORyFrpJfY7pzOTHi4R-bRYKKVx0Sg0vHkIfpNY9dW2ZV5tvtnfo02R7_yhHv2puaII_pdqjNklsQY-9fE4fy1fP_GXmQytmEYPseaISET5wRrLXABftjV_FXWnkt4rlYBI8_RVB8Dl6dGg1wjd0zGWoPoINdG7Y1hihJ2cNg96tirXKBPiKvU65y-rd6jNbxtgDX1vB6nu4pdEOgcIg49bG9kcWde19MUpCVTvQL291opDQcl7JveuscROU64-iYW2hx8BGsBoLVzWtOvUjcHV4Y7AU_oBNWdMblf8eywdDjsxAHYMHrSbPEpxq_wuOki5QVIFEpdrWvVekM7EzVRVoCVp4MKyhq4y1zJdn7sbFT-TYEULNNe9tIrA37YkFFMXY7KglSrOBlI-KKDlljpkOzNlA90lwM34dVj5Q"
}
```

We can go to [jwt.io](https://jwt.io/) to check what data the token contains.

To access a secured resource, we have to pass the token in the header of each request.

Firstly, let's create a simple test controller:

```php
class TestController
{
    #[Route('/api/test')]
    public function __invoke(): Response
    {
        return new JsonResponse('ok');
    }
}
```

And make a request with the JWT token

```
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer [jwt token]" http://localhost/api/test
```

## Refresh Token

Generated JWT tokens have their expiry time. In case the token expires, one of the option is to log in again. There is also another solution - using a refresh token. It's generated together with the regular token and is used to create a new JWT token when the old one expires. A request with a refresh token has to be sent to a specific address. In the response we will receive a new JWT token and new refresh token.

We will use `JWTRefreshTokenBundle` to handle Refresh Tokens. As this tool uses database to store tokens, we will have to install `Symfony ORM Pack` as well.

```
composer require symfony/orm-pack
```

```
composer require gesdinet/jwt-refresh-token-bundle
```

Now we have to configure the refresh token entity which will be used to store this kind of values:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Table;
use Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken;

#[Entity]
#[Table(name: "refresh_tokens")]
class RefreshToken extends BaseRefreshToken
{
    //...
}
```

After that we have to generate a migration:

```
bin/console doctrine:migrations:diff
```

And add the table to the database:

```
bin/console doctrine:migrations:migrate
```

We can configure `JWTRefreshTokenBundle` in `config/packages/gesdinet_jwt_refresh_token.yaml` file:

```yaml
gesdinet_jwt_refresh_token:
    ttl: 2592000
    firewall: api
    token_parameter_name: refresh_token
    single_use: true
    refresh_token_class: App\Entity\RefreshToken
```

We also need a new routing for refreshing the token. We can add it to `config/routes/gesdinet_jwt_refresh_token.yaml` file.

```yaml
gesdinet_jwt_refresh_token:
  path: /api/token/refresh
```

At the end, let's add a new line to our api firewall so that it now looks like this:
```yaml
api:
  pattern:   ^/api
  stateless: true
  entry_point: jwt
  jwt: ~
  refresh_jwt:
    check_path: /api/token/refresh
```

We also have to modify the first entry in the `access_control` section to allow unauthenticated users to refresh their tokens:

```yaml
access_control:
  - { path: ^/api/(login|token/refresh), roles: PUBLIC_ACCESS }
  - { path: ^/api,                       roles: IS_AUTHENTICATED_FULLY }
```

After successfully logging in we will receive a pair of JWT and refresh tokens in the response:

```json
{
    "token":"...",
    "refresh_token":"..."
}
```

To generate a new token based on a refresh token, we have to make a request which will return the same data as during the above process.

```
curl -X POST -d refresh_token="..." http://localhost/api/token/refresh
```

There are multiple ways of storing returned tokens on the client side. One of them can be localStorage if we are using a browser.

## Summary

As you can see, setting up a simple authentication and authorisation system using JWT keys is not complicated and allows us to secure access to an application in a few simple steps.