DEV Community

Jarosław Szutkowski
Jarosław Szutkowski

Posted on

Securing API With JWT In Symfony

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
Enter fullscreen mode Exit fullscreen mode

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

composer require lexik/jwt-authentication-bundle
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pub
JWT_PASSPHRASE=secretkey
Enter fullscreen mode Exit fullscreen mode

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

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' ] }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:
  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
Enter fullscreen mode Exit fullscreen mode

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

  api:
      pattern:   ^/api
      stateless: true
      jwt: ~
Enter fullscreen mode Exit fullscreen mode

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:

# config/routes.yaml
api_login_check:
path: /api/login_check
Enter fullscreen mode Exit fullscreen mode

In the access_control section we have to specify two values:

- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

In the response we receive a token:

{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1ODQyNzY1ODEsImV4cCI6MTU4NDI4MDE4MSwicm9sZXMiOlsiUk9MRV9BRE1JTiJdLCJ1c2VybmFtZSI6ImFkbWluIn0.u6VbYVV_fqSd9Y1_8wUPlzD20cET79EgnEsk19iqVG48db0kx9UFRIzjb62SyhdqPnfWXXsfXGapKS70XMHaxSaOpe7_P2f_bCAkiYJKgwGj6PhjDq8dFhx1AAXPKFWzWBj5mvjMaF0gCQEy00iNzoFhuqBEaK4fl6SqHsM4Nd1TigUsmNg1Kxrz4-G0W8Iz9vGiuRPjMVJzKYMh4iAemvIx8pB3XLYlmgvVvYQd5mMlxIhm4YsVTyXkwijSJPisK-RhORyFrpJfY7pzOTHi4R-bRYKKVx0Sg0vHkIfpNY9dW2ZV5tvtnfo02R7_yhHv2puaII_pdqjNklsQY-9fE4fy1fP_GXmQytmEYPseaISET5wRrLXABftjV_FXWnkt4rlYBI8_RVB8Dl6dGg1wjd0zGWoPoINdG7Y1hihJ2cNg96tirXKBPiKvU65y-rd6jNbxtgDX1vB6nu4pdEOgcIg49bG9kcWde19MUpCVTvQL291opDQcl7JveuscROU64-iYW2hx8BGsBoLVzWtOvUjcHV4Y7AU_oBNWdMblf8eywdDjsxAHYMHrSbPEpxq_wuOki5QVIFEpdrWvVekM7EzVRVoCVp4MKyhq4y1zJdn7sbFT-TYEULNNe9tIrA37YkFFMXY7KglSrOBlI-KKDlljpkOzNlA90lwM34dVj5Q"
}
Enter fullscreen mode Exit fullscreen mode

We can go to 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:

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
composer require gesdinet/jwt-refresh-token-bundle
Enter fullscreen mode Exit fullscreen mode

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

<?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
{
    //...
}
Enter fullscreen mode Exit fullscreen mode

After that we have to generate a migration:

bin/console doctrine:migrations:diff
Enter fullscreen mode Exit fullscreen mode

And add the table to the database:

bin/console doctrine:migrations:migrate
Enter fullscreen mode Exit fullscreen mode

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

gesdinet_jwt_refresh_token:
    ttl: 2592000
    firewall: api
    token_parameter_name: refresh_token
    single_use: true
    refresh_token_class: App\Entity\RefreshToken
Enter fullscreen mode Exit fullscreen mode

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

gesdinet_jwt_refresh_token:
  path: /api/token/refresh
Enter fullscreen mode Exit fullscreen mode

At the end, let's add a new line to our api firewall so that it now looks like this:

api:
  pattern:   ^/api
  stateless: true
  entry_point: jwt
  jwt: ~
  refresh_jwt:
    check_path: /api/token/refresh
Enter fullscreen mode Exit fullscreen mode

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

access_control:
  - { path: ^/api/(login|token/refresh), roles: PUBLIC_ACCESS }
  - { path: ^/api,                       roles: IS_AUTHENTICATED_FULLY }
Enter fullscreen mode Exit fullscreen mode

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

{
    "token":"...",
    "refresh_token":"..."
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (3)

Collapse
 
sanarielsen profile image
Samuel 'Sanarielsen' Henrique

Thanks for the article @jszutkowski.

I have a problem with "api: ... jwt: ", because naturally just only ~ doesnt work in my project, but, I put provider I had created in start of that text and works after.

I will implements more that feature in my structure developed to my university project and probally I continue using that. Again, thanks for the help!

Collapse
 
erkash profile image
Erkin Azimbaev

Thanks mate. What about how to build DDD architecture in Symfony app?

Collapse
 
jszutkowski profile image
Jarosław Szutkowski

There are plans to do so :)