JWT Authentication with Symfony

Reading Time 4 min

Configuring JWT Authentication with Symfony can be quite tricky, especially for beginners. We’ll guide you through a step-by-step tutorial getting you up to speed. We use LexikJWTAuthenticationBundle to setup JWT Auth in less than 10 mins.
JWT Authentication with Symfony

Our setup for JWT Authentication with Symfony

If you are very new to JWT(JSON Web Tokens), it is highly recommended that you have a basic understanding of how it works. Here’s a short video that’ll give you an idea –

We will be using the LexikJWTAuthenticationBundle for configuring JWT Authentication. The steps to setup the same are enlisted below…

1. Setup LexikJWTAuthenticationBundle

Install via composer

# if composer is installed globally
composer require "lexik/jwt-authentication-bundle"
# or you can use php archive of composer
php composer.phar require "lexik/jwt-authentication-bundle"
  • For Symfony 2.x – Symfony 3.3 :
    // Register bundle into app/AppKernel.php:
    public function registerBundles()
    {
        return array(
            // ...
            new Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle(),
        );
    }
    

  • For Symfony 3.4, Symfony 4.x, and above, Flex will have generated the bundles automatically, so no manual intervention is necessary.

2. Generate the SSH Public/Private keys

We create a temporary folder config/jwt to store the public and private keys. Execute the following in the Terminal

# create a folder
$ mkdir -p config/jwt # For Symfony3+, no need of the -p option

# generate the private key and store it in temporary folder
# Provide a strong passphrase when asked and note it.
$ openssl genrsa -out config/jwt/private.pem -aes256 4096

While generating the private, you will be asked for a passphrase. Enter a strong passphrase and note it somewhere as we will need it later to update the configuration. As an example, we’ll use ThisIsThePassPhrase as our passphrase.

# generate the public key using the private key
$ openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem

# You will be asked to verify the passphrase you provided earlier. 

The private(private.pem) and public(public.pem) keys should now be generated in the config/jwt directory.

3. Configure the JWT Public and Private keys

Get the Public and private keys in base64 format

We now obtain the base64 encoded public/private keys by executing the following in the Terminal.

# Prints the private/public keys in base64 format
cat config/jwt/private.pem | base64 | tr -d '\n'        // S09obkNhWjZnaDUVTRFWQ4R0J4MkJMQUtLR0lZNEsxdEQxc....
cat config/jwt/public.pem | base64 | tr -d '\n'         // FQTRRRUdMVTRFWQ4R0J4MkJtS09obkNhW....

Update your .env file

Copy the private/public base64 encoded SSH keys obtained in the previous step and update the values of JWT_SECRET_KEY and JWT_PUBLIC_KEY respectively in the .env file. Also update the JWT_PASSPHRASE to the same passphrase that was provided while generating the keys.

### .env file ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=S09obkNhWjZnaDUVTRFWQ4R0J4MkJMQUt # Base64 encoded Private Key
JWT_PUBLIC_KEY=FQTRRRUdMVTRFWQ4R0J4MkJtS09obkNhW # Base64 encoded Public Key
JWT_PASSPHRASE=ThisIsThePassPhrase               # The same Passphrase you provided earlier

Once this is done, the config/jwt/* folder can be deleted as its not needed anymore. But if you plan to leave it there, remember to not commit it to GIT.

Update the lexik_jwt_authentication config

For Symfony 3.4, 4.x, a configuration file will be generated automatically at config/packages/lexik_jwt_authentication.yaml. Update it as shown below. (For Symfony 3.3 and below, you can add the following in security.yaml)

### config/packages/lexik_jwt_authentication.yaml file ###
lexik_jwt_authentication:
    secret_key:       '%env(base64:JWT_SECRET_KEY)%' # required for token creation
    public_key:       '%env(base64:JWT_PUBLIC_KEY)%' # required for token verification
    pass_phrase:      '%env(JWT_PASSPHRASE)%'        # required for token creation, usage of an environment variable is recommended
    token_ttl:        7200                           # default is 3600 seconds

We now define the route that should be used for generating a valid JWT.

4. Update Route

Define the api_login_check path in your routes.yaml. This route is the endpoint which accepts the user credentials and returns the token(JWT) if authenticated successfully. An error will be returned if the credentials are incorrect/missing.

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

5. Update Firewall

There are 2 common ways in which you can authenticate your user – json_login or form_login.
We will see how to use json_login. Configure your config/packages/security.yaml.

# security.yaml
security:
    # ... 
    firewalls:
        login:
            pattern:  ^/api/login
            stateless: true
            anonymous: true
            json_login: # or form_login
                provider:                 fos_userbundle    #or your custom user provider 
                check_path:               /api/login_check  #same as the configured route
                success_handler:          lexik_jwt_authentication.handler.authentication_success
                failure_handler:          lexik_jwt_authentication.handler.authentication_failure
                require_previous_session: false
        api:
            pattern:   ^/api              # protected path
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

    access_control:
        - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }

6. Obtaining the token

You can test getting the token with a simple curl command in your Terminal like this (adapt host and port):

# test json_login
curl -X POST -H "Content-Type: application/json" http://localhost/api/login_check -d '{"_username":"johndoe","_password":"test"}'
# test form_login
curl -X POST http://localhost:3000/data -d "_username=johndoe&_password=test"

# you should receive something like this as a response:
{
   "token" : "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXUyJ9.eyJleHAiOjE0MzQ3Mjc1MzYsInVzZXJuYW1lIjoia29ybGVvbiIsImlhdCI6IjE0MzQ2NDExMzYifQ.nh0L_wuJy6ZKIQWh6OrW5hdLkviTs1_bau2GqYdDCB0Yqy_RplkFghsuqMpsFls8zKEErdX5TYCOR7muX0aQvQxGQ4mpBkvMDhJ4-pE4ct2obeMTr_s4X8nC00rBYPofrOONUOR4utbzvbd4d2xT_tj4TdR_0tsr91Y7VskCRFnoXAnNT-qQb7ci7HIBTbutb9zVStOFejrb4aLbr7Fl4byeIEYgp2Gd7gY"
}

Store the Token

Once you get the JWT, store it on the client side. You could either use Html5 Web Storage(LocalStorage/SessionStorage) or Cookies. Each has its advantage and disadvantage. Here’s a resource that might be helpful in deciding.
Whatever you decide, ensure that you always send your JWT over https and never http. The JWT is reusable until its ttl has expired (3600 seconds by default).
As an example, we’ll store the token in LocalStorage

// app.js
let token = '';                     // get token from response
localStorage.setItem('jwt', token); // store token in localStorage 

7. Verify/Use the token

To use the token, you need to only pass the JWT with your request to the protected firewall. You can pass it either as an authorization header or as a query parameter.

A. Send JWT as an “Authorization” header

The authorization header mode is enabled by default : Authorization: Bearer {token}
Apache does not support the Bearer authorization scheme. Hence, you will have to explicitly enable Authorization. To enable it, update your .htaccess file as follows:

# .htaccess or /etc/apache2/sites-available/000-default.conf
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

An alternate way is to add the same line as above to the virtual hosts config of apache (/etc/apache2/sites-available/000-default.conf)

While making Ajax requests, you can configure your Client library to add the JWT to every request. Here are a few examples.

// get token (From Web Storage/Cookie)
let token = localStorage.getItem('jwt-token'); 

// Axios
axios.interceptors.request.use(config => {
      config.headers.Authorization = `Bearer ${token}`
      return config;
});

// jQuery,
$.ajaxSetup({
    beforeSend: function(xhr) {
        xhr.setRequestHeader('Authorization', `Bearer ${token}`);
    }
});

// Native javascript
XMLHttpRequest.prototype.realSend = XMLHttpRequest.prototype.send;
var newSend = function(vData) {
    this.setRequestHeader('Authorization', 'Bearer ' + token);
    this.realSend(vData);
};
XMLHttpRequest.prototype.send = newSend;

Now each time you make a request to the protected section (viz /api according to what we defined in the firewall) and pass a valid JWT along with the request, access will be granted. For missing/incorrect/expired JWT, an error will be returned. You could also send the JWT token as a query parameter.

B. Send JWT as a query parameter

Sending JWT as a query parameter is disabled by default. To enable it, update the lexik_jwt_authentication configuration at config/packages/lexik_jwt_authentication.yaml

# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    public_key: '...'
    token_ttl: 10800
    # ...
    token_extractors:
        query_parameter:
            enabled: true
            name:    bearer

Now you can pass your token as a query parameter to the guarded route (/api as defined in the firewall) with the name “bearer” and the value being that of the token. For a valid JWT, access will be granted, otherwise an error will be returned.

8. Logout User

To logout a user, it is sufficient to delete the token from the Web Storage/Cookie. No other manipulation is necessary.
That’s it! You have now setup JWT Authentication with Symfony successfully. Example of a Website using JWT Auth – https://gospelmusic.io


So far, We discussed how to obtain a JWT, to validate/use it and to delete it. When you further develop your App, the following scenarios might be helpful…

Configuring a Route as both Secure and Unsecure

Sometimes, you might wanna allow a certain route to be accessible even without authentication. To achieve that, we write our own JWTAuthenticator class as follows:

// src/Security/JWTAuthenticator.php
namespace App\Security;

use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
// use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; // For Symfony 4.4 and above

final class JWTAuthenticator extends JWTTokenAuthenticator
{
    private $firewallMap;

    public function __construct(
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        TokenExtractorInterface $tokenExtractor,
        // TokenStorage $tokenStorage, // For Symfony 4.4 and above
        FirewallMap $firewallMap
    ) {
        parent::__construct($jwtManager, $dispatcher, $tokenExtractor);
        // For Symfony 4.4 and above, use the next line instead of the above one
        // parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $tokenStorage);

        $this->firewallMap = $firewallMap;
    }

    /* For Symfony 3.x and below */
    public function getCredentials(Request $request)
    {
        try {
            return parent::getCredentials($request);
        } catch (AuthenticationException $e) {
            $firewall = $this->firewallMap->getFirewallConfig($request);
            // if anonymous is allowed, do not throw error
            if ($firewall->allowsAnonymous()) {
                return;
            }

            throw $e;
        }
    }

   /* For Symfony 4.x and above */
   public function supports(Request $request) {
        try {
            return parent::supports($request) && parent::getCredentials($request);
        } catch (AuthenticationException $e) {
            $firewall = $this->firewallMap->getFirewallConfig($request);

            // if anonymous is allowed, skip authenticator
            if ($firewall->allowsAnonymous()) {
                return false;
            }

            throw $e;
        }
    }
} 

Register this class as a service by adding the following to your services.yaml file-

app.jwt_authenticator:
        #autowire: false     # uncomment if you had autowire enabled.
        autoconfigure: false
        public: false
        parent: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        class: App\Security\JWTAuthenticator
        arguments: ['@security.firewall.map']

And then update the firewall in security.yaml to use the newly registered service

    api:
            pattern:   ^/api
            stateless: true
            guard:
                authenticators:
                    - app.jwt_authenticator

That’s it. Now even if you provide an invalid token, the request will not fail since you allow anonymous access too!

Creating a Rest API

Here’s a great resource that describes on how you could create a REST API using Symfony 4, LexikJWTAuthBundle and NelmioCorsBundle.

Hope that helps 🙂 !

References

4 comments

  1. Once this is done, the config/jwt/* folder can be deleted as its not needed anymore. But if you plan to leave it there, remember to not commit it to GIT.

    Why, not commit?

    1. You should not commit to GIT because anyone who has access to your repository will also be able to access your public and private keys that are used to generate your secure JWT’s.

  2. What is the point of `pattern: /api/login`, why it’s different than `check_path` and not just `/api/login_check`?

    1. The /api/login path is actually authenticated anonymously and is used to only display a user login form. When you submit the form, its target is the path /api/login_check which is used to verify your login and return a JWT if the login was successful. The route “check_path” is similar to /api/login_check excepting that it is specifically used for json_login (i.e. when you want the form data is submitted as json-encoded data)

Leave a Reply