Symfony as JWT provider
In guide we'll give you all the information you need to start using Symfony as a JWT provider.
In the process of writing this guide Ryan Weaver passed away. This news was truly shocking to me and my thoughts are with his family and loved ones.
I hope this guide can do some justice to his spirit.
So, in my previous article I talked about why I decided to switch from Auth0 to Symfony as my authentication provider. In that article I also promised to write a follow up guide on how I actually implemented it.
For some context this guide is not about authenticating to Symfony with JWT rather it is about using Symfony to authenticate users to other systems. The setup used in this guide is quite simple.
Now Symfony also houses the user and authentication system and that flow goes as follows:
In this guide I will only go over the Symfony side of this system. so it will require you to implement the other areas yourself, however for most of these you can follow Auth0's guides since its basically the same set up.
This is a bit more of an advanced guide and its quite loose in the sense I expect you to apply it to your situation, so blindly copy and pasting this code and commands will not solve your problem.
I also presume you have / know the following:
To start something to mention is that we will be using web-token/jwt-bundle
, all the classes in this bundle are in the Jose\
namespace, and i'm not sure why, I was also confused. I will however refer to this bundle as Jose since its shorter.
To begin we must install the aforementioned bundle:
composer require web-token/jwt-bundle web-token/jwt-signature-algorithm-rsa
This Jose bundle is a framework for working with JWT and its the foundation for this setup.
Now that we have installed the bundle we will begin by configuring it, and to do this we must generate the keys. The keys will live in the config/jwt/
folder but you can change this if you want.
Note: you should not commit these key's to git
Run the following commands:
mkdir config/jwt
php bin/console keyset:generate:rsa --random_id --use=sig --alg=RS256 3 4096 > config/jwt/signature.jwkset
php bin/console key:generate:rsa 2048 --use=sig --alg=RS256 --random_id > config/jwt/signature.jwk
php bin/console keyset:rotate `cat config/jwt/signature.jwkset` `cat config/jwt/signature.jwk` > config/jwt/signature.jwkset
php bin/console keyset:convert:public `cat config/jwt/signature.jwkset` > config/jwt/public.jwkset
Note: If you have read the docs, they use
./jose.phar
however the commands should just start withphp bin/console
this goes for alljose
commands. So no need to mess with./jose.phar
I will quickly go over what each of these commands does, to give some context.
config/jwt
folderNow one nice thing about this JWT setup is that we can change our private key without breaking everything. so if its compromised you simply run the last 3 commands and the whole system will use a new private key, while the existing signed JWT's are still valid since their public keys remain in the public key set.
To finish off the configuration of Jose we must set up the config file if you followed the steps so far you can simply copy and paste it.
# /config/packages/jose.yaml
parameters:
env(JWT_PRIV): '%kernel.project_dir%/config/jwt/signature.jwk'
env(JWTSET_PRIV): '%kernel.project_dir%/config/jwt/signature.jwkset'
env(JWTSET_PUB): '%kernel.project_dir%/config/jwt/public.jwkset'
jose:
keys:
user_sig_priv:
jwk:
value: '%env(file:JWT_PRIV)%'
is_public: false
key_sets:
user_sig_priv:
jwkset:
value: '%env(file:JWTSET_PRIV)%'
is_public: false
user_sig_pub:
jwkset:
value: '%env(file:JWTSET_PUB)%'
is_public: false
jws:
builders:
builder:
signature_algorithms: [ 'RS256' ]
is_public: true
verifiers:
verifier:
signature_algorithms: [ 'RS256' ]
is_public: true
All this config basically does is allow us to use the keys we just created more easily within our Symfony project. it also sets up a builder and a verifier service which we'll use later. After doing this the JWT icon should appear in the debug toolbar (on any page) and it will show 1 key and 2 keysets.
Now this guide includes the use of refresh tokens (RT). These are tokens stored in the database that the user can exchange for a new JWT and RT. The reason we want this is so we can keep the TTL of the refresh token very short. which has various advantages (which I won't list), and as long as the RT doesn't expire or is removed from the db the user remains logged in.
To store the RT in the database we obviously need to create an entity, here is a stub of the one I use:
# src/Entity/RefreshToken.php
use App\Repository\RefreshTokenRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: RefreshTokenRepository::class)]
class RefreshToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
#[ORM\Column]
private ?\DateTime $expires = null;
#[ORM\ManyToOne(inversedBy: 'refreshTokens')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column]
private ?\DateTime $issued = null;
//GETTERS AND SETTERS NOT INCLUDED
}
If you want to you can change this entity, add whatever fields you'd like. You can change the $user
property to something else so that the token doesn't have to be associated with a User
object . this offers more options but also more complications. Also the $issued
property is technically not needed, but I like having it.
Now with all that setup out of the way I introduce to you the JWTService
. This service will do all that you need for most JWT setups:
# src/Service/JWTService.php
namespace App\Service;
use App\Entity\RefreshToken;
use App\Entity\User;
use Jose\Component\Core\JWK;
use Jose\Component\Core\JWKSet;
use Jose\Component\Signature\JWS;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
class JWTService
{
private const ALG = 'RS256';
private readonly JWSBuilder $builder;
private readonly JWSVerifier $verifier;
private readonly CompactSerializer $serializer;
public function __construct(
private readonly JWK $userSigPrivKey,
private readonly JWKSet $userSigPubKeySet,
JWSBuilder $builderJwsBuilder,
JWSVerifier $verifierJwsVerifier
)
{
$this->builder = $builderJwsBuilder;
$this->verifier = $verifierJwsVerifier;
$this->serializer = new CompactSerializer();
}
public function generateJWS(User $user): JWS
{
$claims = [
'sub' => $user->getEmail(),
'data' => [
//This array is where you can store some signed information about the user
'email' => $user->getEmail(),
],
'iat' => time(),
'exp' => time() + 3600 //This decides the JWT TTL,
];
$jws = $this->builder->create()
->withPayload(json_encode($claims))
->addSignature($this->userSigPrivKey, [
'alg' => self::ALG,
'kid' => $this->userSigPrivKey->get('kid')
])
->build();
return $jws;
}
public function createRT(User $user)
{
$token = bin2hex(random_bytes(64));
$rt = (new RefreshToken())
->setUser($user)
->setToken($token)
->setExpires((new \DateTime())->modify('+30 days')) //This decides the RT TTL
->setIssued(new \DateTime());
return $rt;
}
public function generateJWT(User $user): string
{
$jws = $this->generateJWS($user);
return $this->serializeJWS($jws);
}
public function verifyJWS(JWS $jws): bool
{
$valid = $this->verifier->verifyWithKeySet($jws, $this->userSigPubKeySet, 0);
return $valid;
}
public function JWSExpires(JWS $jws): \DateTime
{
$payload = json_decode($jws->getPayload(), true);
return date_create_from_format('U', $payload['exp']);
}
public function serializeJWS(JWS $jws): string
{
return $this->serializer->serialize($jws);
}
public function unserializeJWT(string $JWT): JWS
{
return $this->serializer->unserialize($JWT);
}
}
The only things you might want to take a look at are the createRT
and generateJWS
functions these two might need adjusting depending on your situation. Primarily the generateJWS
function's data array. in this array you can store any information that you want to be signed. However this information can be decoded by anyone since its just base64. So only put things in there which are not super critical i.e. passwords or api keys.
In this code the TTL of both the RT and JWT are defined, change them however you like, I personaly found JWT 10 min and RT 1 month to work well for my situation.
Now that we've got our JWTService
setup we need to create 3 endpoints
Here is a simple controller that does these 3 things:
<?php
namespace App\Controller;
use App\Entity\RefreshToken;
use App\Service\JWTService;
use Doctrine\ORM\EntityManagerInterface;
use Jose\Component\Core\JWKSet;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface;
class JWTController extends AbstractController
{
public function __construct(
private readonly JWTService $jwtService,
private readonly EntityManagerInterface $entityManager
)
{
}
#[Route('/.well-known/jwks.json', name: 'jwt_jwks')]
public function jwks(JWKSet $userSigPubKeySet)
{
return $this->json($userSigPubKeySet);
}
#[Route('/jwt/request', name: 'jwt_request')]
public function request(UserInterface $user)
{
$jws = $this->jwtService->generateJWS($user);
$rt = $this->jwtService->createRT($user);
$this->entityManager->persist($rt);
$this->entityManager->flush();
return $this->json([
'token' => $this->jwtService->serializeJWS($jws),
'token_expires' => $this->jwtService->JWSExpires($jws)->format('U'),
'refresh_token' => $rt->getToken(),
'refresh_expires' => $rt->getExpires()->format('U')
]);
}
#[Route('/jwt/refresh', name: 'jwt_refresh', methods: ['POST'])]
public function refresh(Request $request)
{
try {
$body = json_decode($request->getContent(), true);
$token = $body['refresh_token'];
} catch (\Throwable $th) {
return new Response('Bad request', 400);
}
$oldRt = $this->entityManager->getRepository(RefreshToken::class)->findOneBy(['token' => $token]);
if (!$oldRt) {
return new Response('non-existent token', 404);
}
if ($oldRt->getExpires() < new \DateTime('now')) {
return new Response('Token expired', 400);
}
$user = $oldRt->getUser();
$this->entityManager->remove($oldRt);
$jws = $this->jwtService->generateJWS($user);
$newRt = $this->jwtService->createRT($user);
$this->entityManager->persist($newRt);
$this->entityManager->flush();
return $this->json([
'token' => $this->jwtService->serializeJWS($jws),
'token_expires' => $this->jwtService->JWSExpires($jws)->format('U'),
'refresh_token' => $newRt->getToken(),
'refresh_expires' => $newRt->getExpires()->format('U')
]);
}
}
Again, you can and should change this controller in what ever way best suits your situation.
Somethings to note, the jwt_jwks
is fully public. This is not a (big) security risk as all this allows is validation of the signature. however it is generally advisable to also limit access to this route if possible.
Here are some things you should now also do:
jwt_jwks
public.If you have any questions or find problems in this guide I would love to help clarify / fix them @TomvdPeet. But keep in mind that this guide is supposed to be followed loosely and modified to your needs.
This article is 100% human written
Lastly this guide is provided “as is” for informational purposes. Use at your own risk. No warranties. I accept no liability for any loss or damage arising from its use.
If you enjoyed this blog, you should really
Start for free in just 2 clicks
Install nowIn guide we'll give you all the information you need to start using Symfony as a JWT provider.
In this more technical article we'll go over: why we went for Auth0, the problems we ran into, and why we pivoted to Symfony
AI tools today are intelligent but still heavily reliant on manual user input: typing prompts, copying context, formatting screenshots, etc.