The Guide I Wish I Had to Implement a Jwt Auth in Symfony
Loïc Chau8 min read
 
 Symfony is a great framework with very exhaustive documentation; too exhaustive sometimes. As I started my project I got lost too many times in the documentation… This is the article I wish I had to guide me through the implementation of authentication with JWTs and refresh tokens.
It is a step-by-step guide with explanations to guide you through the implementation of a perfect JWT authentication.
For the explanation to be as precise as possible I’ll provide you with links to an example repo that implements a JWT authentication in Symfony following the instructions described in this article. You can find the final implementation in this repository.
Packages that will be used
- lexik/jwt-authentication-bundlefor the JWTs
- gesdinet/jwt-refresh-token-bundlefor the refresh tokens
Outline
- Setting up the JWT authentication
- Setting up the refresh tokens
- Next steps
Recap on what JWT authentication is
A JWT authentication grants its users authorizations based on an access token in the JSON Web Token (JWT) format. This access token contains all information necessary for authorization in opposition to sessions which typically only store the user’s id. JWTs are most often paired with refresh tokens for security and UX purposes.
Setting up the JWT authentication
We will install the lexik/jwt-authentication-bundle bundle as per the instructions of the README.
Install the lib with composer
composer require lexik/jwt-authentication-bundle
Thanks to Symfony Flex, most files will be created for you when you run the composer command.
Generate the private and public keys
php bin/console lexik:jwt:generate-keypair
Configure the bundle
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 300 # 5min
when@dev:
    lexik_jwt_authentication:
        token_ttl: 43200 # 12h
The secret_key, public_key and pass_phrase config has already been generated by the Symfony Flex recipe. Do not forget to change the pass_phrase in production !
By default the time to live (TTL) of the access token is of one hour which is very long. Since we will be using refresh tokens you should lower the TTL to 5 minutes to be secure.
Update security.yaml
We set up:
- firewalls (how to authenticate users on given paths)
- access control (who can access given paths)
# config/packages/security.yaml
security:
    enable_authenticator_manager: true
    # ...
    # This provider part should have been generated when installing the Security bundle
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        api:
            pattern:   ^/api
            stateless: true
            jwt: ~
    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }
Note on the firewalls
- loginto wire the JWT bundle login to the- /api/loginroute
- apithat enforces- jwtauthentication on all routes starting with- /api
The api firewall declaration does not interfere with /api/login because it is declared after the login firewall.
Note that we have defined all our firewalls to be stateless. This means that we do not want to use the default session authentication provided by Symfony since it would be redundant with the JWTs.
Note on the access control roles
- IS_AUTHENTICATED_FULLY : user must be authenticated to access the pages
- PUBLIC_ACCESS : no check is performed, anyone can access the pages
Unfortunately, there is no list with all roles that you can use in the access_control roles property. They are individually referenced under corresponding use cases in the Access control section of the documentation.
Finally, declare the login route in the routes.yaml config file
# config/routes.yaml
# Add the following config to your file :
api_login_check:
    path: /api/login_check
Use your access token
- Make a POSTrequest on/api/login_checkwith one of your user’s credentials in the body{email: xxx, password: xxx}to retrieve an access token
- Set the value Bearer MY_JSON_WEB_TOKENin the Authorization Header of your HTTP calls to access authenticated routes
Conclusion of the JWT setup
You now have a login route on /api/login_check that returns a JWT token providing users access to authenticated parts of your app!
Setting up the refresh tokens
We now have very secure access tokens with a short time to live. The issue is that it would become very quickly annoying for users to have to relogin every 5 minutes to get a new access token… Refresh tokens are longer lived and will allow the user to get a new access token without going through login, thus balancing security and user experience.
To add this feature to our Symfony application we will install and set up the gesdinet/jwt-refresh-token-bundle.
Install the bundle
composer require gesdinet/jwt-refresh-token-bundle
Press y when prompted if you want to execute the associated recipe, this will automatically setup most of the bundle.
Update security.yaml
security:
		# ...
    firewalls:
        # ...
        refresh_token:
            pattern: ^/api/token/refresh
            stateless: true
            refresh_jwt:
                # The corresponding route has been declared by the recipe
                check_path: /api/token/refresh
        # Make sure the refresh token firewall is above the api firewall !!
        # Else the requests will match the api firewall first
        api:
            # ...
    # ...
    access_control:
        # ...
        # enable public access to token refresh route for users logged
        # out because their token expired
        - { path: ^/api/(login|token/refresh), roles: PUBLIC_ACCESS } 
        # ...
# ...
- We create a new firewall dedicated to the refresh token route (similar to the login firewall).
- Make sure to place it before the apifirewall ! Else theapifirewall will always match before the refresh token route, making the latter useless.
- We set the refresh token route to be public since it will be used by logged-out users
Configure the refresh token bundle
gesdinet_jwt_refresh_token:
    refresh_token_class: App\Entity\RefreshToken # Scaffolded by the bundle recipe
    ttl: 7200 # 2h in seconds
    single_use: true
    # Use cookies for the refresh token
    cookie:
        enabled: true
        remove_token_from_body: true
        # Cookie parameters
        http_only: true
        same_site: strict
        secure: true
        path: /api/token
        domain: null
- We set a custom TTL for the refresh token. Its duration should match how long you allow a user to be idle without re-logging.
- We set the refresh tokens to be single_usefor better security. It means every time you use a refresh token you get a new one and the old one is invalidated.
- We set attributes to pass our cookie:
- HttpOnly=trueto use an HttpOnly cookie. This way the refresh token will be inaccessible using JavaScript, mitigating XSS attacks. Moreover, it is ok to use it in cookies that are automatically sent by your browser because a refresh token does not authenticate you. Therefore we limit CSRF attack concerns.
- Secure=trueto make sure the cookie is only sent over HTTPS
- SameSite=Strictto avoid our cookie to be used in another context
- Path=/api/tokento only send our cookie on the refresh token routes
- Do not specify Domain, as it will be automatically set
 
The five cookie attributes we just defined are an exhaustive list of the cookie options exposed by the bundle.
Conclusion of the Refresh token setup
Upon login, a refresh token cookie will now be sent to your browser. It is up to you to send a request to /api/token/refresh with the refresh token cookie to get a new access token. This route returns the exact same payload as the login one.
Next steps
Congratulations! You now have a JWT and refresh token authentication in your Symfony app 🥳
There are two next steps you should follow to leverage the full potential of JWTs :
- Add custom authorization data to your JWT payload such as the user’s roles, this way it will be accessible to your frontend.
- Use a db-less provider in your main firewall. Indeed after following this tutorial, Symfony still uses the Entity User Provider and issues an SQL query on every HTTP request to check your user authorizations. It is a shame because the JWT is supposed to already contain all the authorizations! So make sure to make your app as performant as it can.
 
  
 