The input text has been transformed into a sarcastic summary with a twist. Here’s a sarcastic version of the original text, including the following changes:
In the realm of JWT authentication, the situation for a user has taken a drastic turn. Ryan Weaver, a renowned author, has passed away, leaving behind a legacy of writing guides on how to set up and use JWT with Auth0 instead. This news is not just shocking, but also a stark reminder of the fragility of life and the unpredictability of technology.
As the user attempts to navigate with his newfound loss, they come across a series of refresh tokens (RTs) in the database, a testament to the life of the beloved author’s time spent writing and sharing his knowledge. These RTs, once secured with a private key, are now vulnerable to a series of refresh token expires and refresh token expiration issues, causing a refresh token expiration headache for the user.
As the user struggles to keep up with the evolving landscape of JWT, they find themselves grappling with the complexities of JWT expiration and the need to refresh their tokens
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.
- The user interacts through the browser extension.
- These interactions (e.g. LLM streaming and such) go through Node.js.
- Symfony is setup as a back-end API for handling data.
Now Symfony also houses the user and authentication system and that flow goes as follows:
- The user logs into the Symfony application
- The extension requests a JWT and Refresh token (RT)
- The extension calls to Node.js with the JWT and Node.js validates it
- When the JWT expires the RT is exchanged for a new RT and JWT
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.
Before we begin
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:
- An up and running symfony application with users and login
- Some knowledge of how JWT works and what it is
- Enough knowledge of security to apply this in a safe way
Configuration
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.
- creates the
config/jwt
folder - generates a private keyset of 3 keys (none of which we’ll use)
- generates a single private key that we will use for signing our JWT’s
- rotates our newly generated real key into the key set
- creates a public keyset that other systems will use to verify our signed JWT’s
Now 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.
Refresh tokens
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.
Generating JWT’s
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.
Providing JWT’s
Now that we’ve got our JWTService
setup we need to create 3 endpoints
- to share our public key set (JWKS)
- for the user to login and request an initial JWT and RT
- for exchanging an RT for a new JWT and RT
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.
In closing
Here are some things you should now also do:
-
rotate your keys, I’d recommend setting up something that rotates the private key very day or so or on deployment, this is especially recommended if you keep the
jwt_jwks
public. - Cleanup RT’s It is also recommended to set up a cron job that deletes any expired RT’s since there is no reason to keep them.
- Set up testing setup proper testing to make sure your system is well protected.
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.
Original article: https://browsely.ai/blog/symfony-as-jwt-provider