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-bundle
for the JWTsgesdinet/jwt-refresh-token-bundle
for 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
login
to wire the JWT bundle login to the/api/login
routeapi
that enforcesjwt
authentication 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
POST
request on/api/login_check
with 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_TOKEN
in 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
api
firewall ! Else theapi
firewall 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_use
for 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=true
to 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=true
to make sure the cookie is only sent over HTTPSSameSite=Strict
to avoid our cookie to be used in another contextPath=/api/token
to 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.