Laravel 11 JWT Authentication: A Step-by-Step Guide with Asymmetric Keys
Housni BENABID
Tech Lead Full-stack Software Engineer | Former Prestashop Ambassador | Passionate about Golf & Chess
Due to the specific requirements of your SaaS application, you might need more control over the authentication features. While Laravel offers robust authentication packages such as Sanctum, there are scenarios where you may prefer to implement custom authentication logic.
In this article, I will demonstrate how to create an out-of-the-box application that can serve as a template for your future projects. This will allow you to implement your custom authentication logic quickly and efficiently.
Let’s get started. We’ll assume that we’re working on a completely new Laravel 11 application. First, we need to create our application and navigate to its directory:
# Initialize the application called "my-auth-app"
composer create-project laravel/laravel my-auth-app
#Go to /my-auth-app folder
cd my-auth-app
Install dependencies
To handle encoding and decoding JWTs, we need to install a suitable library. After reviewing available options on jwt.io, I’ve decided to use the lcobucci/jwt library. Let’s proceed with the installation by running the following command in the console from the root directory of your application:
composer require lcobucci/jwt
Set storage for private/public keys
We may want to have a folder to store our private/public key
# Create /jwt folder in /storage
mkdir storage/jwt
The issue we will face here is that the /jwt folder will be ignored in your git project as all storage content except /storage/public are ignored. To fix that let open our /storage/.gitignore and add /storage/jwt folder as exception by adding add !jwt/ under !public/:
The issue we will face here is that the /jwt folder will be ignored in your Git project since all storage content, except for /storage/public, is ignored by default. To fix this, we need to modify our /storage/.gitignore file and add the /storage/jwt folder as an exception. You can do this by adding the line !jwt/ under !public/:
*
!public/
!jwt/
!.gitignore
This will ensure that the /storage/jwt folder is tracked by Git.
For security reasons, it is crucial to keep the keys on the server and not include them in your Git repository. To ensure this, we need to create a .gitignore file inside the /storage/jwt folder. This file will prevent the keys from being tracked by Git.
Create the .gitignore file with the following content:
*
!.gitignore
With the above approach, you will have a secure folder on your server where you can store your public and private keys safely. This ensures that sensitive key information is not included in your Git repository, maintaining the security of your application.
Create JwtHelper
To maintain consistent and clean code, let’s avoid redundancy by creating a custom helper. This helper will allow us to store and manage our custom JWT configuration effectively.
# Create app/Helpers folder if it doesn't exists
mkdir app/Helpers
# Create the helper file
touch app/Helpers/JwtHelper.php
In this helper, we need to implement the getJwtConfiguration() method, which will return an instance of Lcobucci\JWT\Configuration. To achieve this, we first need to define the paths for the private and public keys.
Let’s start by defining the variables $privateKeyPath and $publicKeyPath to store these paths.
// Ensure that Storage::path('jwt/private.pem') returns a non-empty string
$privateKeyPath = Storage::path('jwt/private.pem');
if (empty($privateKeyPath)) {
throw new \InvalidArgumentException('Private key path cannot be empty.');
}
// Ensure that Storage::path('jwt/public.pem') returns a non-empty string
$publicKeyPath = Storage::path('jwt/public.pem');
if (empty($publicKeyPath)) {
throw new \InvalidArgumentException('Public key path cannot be empty.');
}
Now we need to create a configuration we’re going to use for all our interactions with tokens (create and parse). Based on lcobucci-lwt documentatio, let’s return the configuration with Asymmetric signing involves a pair of keys: a private key for signing and a public key for verification with SHA-256 hashing algorithm.
Next, we need to create a configuration that will be used for all our interactions with tokens, including creation and parsing. According to the lcobucci/jwt documentation, we’ll return the configuration with asymmetric signing, which involves a pair of keys: a private key for signing and a public key for verification, utilizing the SHA-256 hashing algorithm.
return Configuration::forAsymmetricSigner(
new Sha256(),
InMemory::file($privateKeyPath),
InMemory::file($publicKeyPath)
);
The file app/Helpers/JwtHelper.php will look like the following
<?php
namespace App\Helpers;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Illuminate\Support\Facades\Storage;
class JwtHelper
{
public static function getJwtConfiguration(): Configuration
{
// Ensure that Storage::path('jwt/private.pem') returns a non-empty string
$privateKeyPath = Storage::path('jwt/private.pem');
if (empty($privateKeyPath)) {
throw new \InvalidArgumentException('Private key path cannot be empty.');
}
// Ensure that Storage::path('jwt/public.pem') returns a non-empty string
$publicKeyPath = Storage::path('jwt/public.pem');
if (empty($publicKeyPath)) {
throw new \InvalidArgumentException('Public key path cannot be empty.');
}
return Configuration::forAsymmetricSigner(
new Sha256(),
InMemory::file($privateKeyPath),
InMemory::file($publicKeyPath)
);
}
}
You may wonder how to create private/public keys. Do not worry we will get to this part later ;)
Set up User eloquent model
For security purpose, we may add uuid records for our User model as we’re going to add user uuid in our token claims. Laravel offers a quick way to make your model supports uuid in this section. Let’s do it ;)
Now let’s use the Illuminate\Database\Eloquent\Concerns\HasUuids trait on the User model.
For security purposes, we should add UUID records to our User model since we’ll be including the user UUID in our token claims. Laravel provides a convenient way to enable UUID support for your models.
Let’s proceed by using the Illuminate\Database\Eloquent\Concerns\HasUuids trait on the User model. Here’s how you can do it:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use HasUuids;
// ...
}
To ensure the auto increment of uuid let’s create a migration file to alter users table and add uuid column.
# Create migration file
php artisan make:migration AddUuidColumnToUsersTable
Laravel will create a file in your /database/migrations folder similar to 2024_07_01_230045_add_uuid_column_to_users_table.php. Let’s open the file and copy/paste the following code.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// We add the uuid column right after the id
$table->uuid('uuid')->unique()->after('id')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
// Drop the uuid column
$table->dropColumn('uuid');
});
}
};
Have extra control over tokens
To have control on generated tokens we may need to create a jwt_tokens table to store tokens and validate their important records like permissions, user_uuid, unique_id, description, expiration date, and maybe last used time.
To have control over the generated tokens, we need to create a jwt_tokens table to store tokens and validate their important attributes, such as permissions, user_uuid, unique_id, description, expiration date, and possibly the last used time.
We don’t have to create an eloquent model for that so let’s just create a migration file to create the table.
# Create migration file
php artisan make:migration CreateJwtTokensTable
Laravel will create a file in your /database/migrations folder similar to 2024_07_01_233530_create_jwt_tokens_table.php. Let’s open the file and copy/paste the following code.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jwt_tokens', function (Blueprint $table) {
$table->id();
$table->uuid('user_uuid');
// Foreign key to uuid column of users table
$table->foreign('user_uuid')->references('uuid')->on('users')->onDelete('cascade');
$table->string('unique_id')->unique()->index();
$table->string('description');
$table->json('permissions')->nullable();
$table->timestamps();
$table->timestamp('expires_at')->nullable();
$table->timestamp('last_used_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jwt_tokens');
}
};
Token manipulation
All token operations are related to User model. Let’s create a trait to create/destroy token.
# Create app/Traits folder where we sotre the trait file
mkdir app/Traits
# Create app/Traits/JWTAuthTrait.php file
touch app/Traits/JWTAuthTrait.php
Let’s call the config :
use App\Helpers\JwtHelper;
$config = JwtHelper::getJwtConfiguration();
Now, we generate the token :
$date = new \DateTimeImmutable();
$uniqueID = uniqid();
// Generate the token with appropriate claims
$token = $config->builder()
->issuedBy(config('app.url')) // Configures the issuer (iss claim)
->permittedFor(config('app.url')) // Configures the audience (aud claim)
->identifiedBy($uniqueID) // Configures the id (jti claim)
->relatedTo($this->uuid) // Configures the subject (sub claim)
->issuedAt($date) // Configures the time that the token was issued (iat claim)
->canOnlyBeUsedAfter($date) // Configures the time that the token can be used (nbf claim)
->expiresAt($date->modify('+1 week')) // Configures the expiration time of the token (exp claim)
->getToken($config->signer(), $config->signingKey()); // Retrieves the generated token
In the above code, we initiate the configuration. For enhanced security, we generate the token in compliance with the RFC 7519 guidelines. To achieve this, we need to provide the required claims: - issuedBy to add “iss” (Issuer) - permittedFor to add “aud” (Audience) - identifiedBy to add “jti” (JWT ID) - relatedTo to add “sub” (Subject) - issuedAt to add “iat” (Issued At) - canOnlyBeUsedAfter to add “nbf” (Not Before) - expiresAt to add “exp” (Expiration Time)
After the token is generated, let’s add it to our jwt_tokens table. Here’s an example of how you can achieve this:
领英推荐
// insert the token record in database
DB::table('jwt_tokens')->insert([
'user_uuid' => $this->uuid,
'unique_id' => $uniqueID,
'description' => $action." ".$this->email,
'permissions' => null,
'expires_at' => $date->modify($expiration),
'last_used_at' => null,
'created_at' => $date,
'updated_at' => $date,
]);
To destroy a token, the only operation we need is to remove the token from jwt_tokens table.
// Delete the token record
!DB::table('jwt_tokens')->where([
'unique_id' => $uniqueID,
'user_uuid' => $this->uuid
])->delete()
The file app/Traits/JWTAuthTrait.php will look like this:
<?php
namespace App\Traits;
use App\Helpers\JwtHelper;
use Illuminate\Support\Facades\DB;
trait JWTAuthTrait
{
public function createToken(string $action = 'authToken', string $expiration = '+1 week'): string
{
// Ensure $this->uuid is a non-empty string
if (empty($this->uuid)) {
return '';
}
$config = JwtHelper::getJwtConfiguration();
$date = new \DateTimeImmutable();
$uniqueID = uniqid();
// Generate the token with appropriate claims
$token = $config->builder()
->issuedBy(config('jwt.issuer')) // Configures the issuer (iss claim)
->permittedFor(config('jwt.audience')) // Configures the audience (aud claim)
->identifiedBy($uniqueID) // Configures the id (jti claim)
->relatedTo($this->uuid) // Configures the subject (sub claim)
->issuedAt($date) // Configures the time that the token was issued (iat claim)
->canOnlyBeUsedAfter($date) // Configures the time that the token can be used (nbf claim)
->expiresAt($date->modify($expiration)) // Configures the expiration time of the token (exp claim)
->getToken($config->signer(), $config->signingKey()); // Retrieves the generated token
// insert the token record in database
DB::table('jwt_tokens')->insert([
'user_uuid' => $this->uuid,
'unique_id' => $uniqueID,
'description' => $action." ".$this->email,
'permissions' => null,
'expires_at' => $date->modify($expiration),
'last_used_at' => null,
'created_at' => $date,
'updated_at' => $date,
]);
$this->token = $token->toString();
return $token->toString();
}
public function destroyToken(string $tokenString): void
{
if (empty($tokenString)) {
throw new \Exception('Token string is empty');
}
// Parse token
$config = JwtHelper::getJwtConfiguration();
$token = $config->parser()->parse($tokenString);
if (!method_exists($token, 'claims')) {
throw new \Exception("Invalid Token", 1);
}
// Check if the token belongs to the user
$userUUID = $token->claims()->get('sub');
$uniqueID = $token->claims()->get('jti');
if (!$userUUID || !$uniqueID || $this->uuid !== $userUUID) {
throw new \Exception('Invalid token');
}
if(
// Delete the token record
!DB::table('jwt_tokens')->where([
'unique_id' => $uniqueID,
'user_uuid' => $this->uuid
])->delete()
) {
throw new \Exception('Invalid token');
}
}
}
Now let’s use this trait in the User model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Foundation\Auth\User as Authenticatable;
use App\Traits\JWTAuthTrait;
class User extends Authenticatable
{
use HasUuids;
use JWTAuthTrait;
// ...
}
Create custom guard
Let’s proceed by creating a custom guard in the app/Guards directory. If the directory does not already exist, make sure to create it.
# Create the folder
mkdir app/Guards
#create the guard file
touch app/Guards/JwtGuard.php
Next, copy and paste the following content. To implement the Guard class correctly, ensure that you define all the necessary methods: check, guest, user, id, validate, hasUser, setUser, and attempt. This will ensure that the Guard class adheres to the required structure.
Let’s proceed by creating the constructor:
protected $user;
protected UserProvider $provider;
protected Request $request;
protected Configuration $config;
public function __construct(UserProvider $provider, Request $request)
{
// Define the Provide and The Request incoming from the middleware
$this->provider = $provider;
$this->request = $request;
// Define the auth configuration we need in the guard
$this->config = JwtHelper::getJwtConfiguration();
}
In the constructor, we define the following dependencies: Illuminate\Http\Request, Illuminate\Contracts\Auth\UserProvider, and the JWT configuration from our custom helper, App\Helpers\JwtHelper::getJwtConfiguration.
Now let’s retrieve the user:
public function user()
{
// check if it's already defined to skip the process
if ($this->hasUser()) {
return $this->user;
}
try {
// Check if we have the bearer token with the ncoming Request
$token = $this->request->bearerToken();
if (!$token) {
return null;
}
// If token exists let's parse it, based on our private/public keys
$token = $this->config->parser()->parse($token);
if(!method_exists($token, 'claims')) {
return null;
}
// Check if there are any token constraints
$constraints = $this->config->validationConstraints();
if(!empty($constraints)) {
// If constraints exists let's validate them
$this->config->validator()->assert($token, ...$constraints);
}
// Retrive the user_uuid claim, stop the process if is missing
$uuid = $token->claims()->get('sub');
if(!$uuid) {
return null;
}
// Check if user exists with the uuid above
$user = User::where('uuid', $uuid)->first();
if(!$user) {
return null;
}
// We set the user matching the records and we validate the token
$this->setUser($user);
$this->validateToken($token);
return $this->user;
} catch (\Exception $e) {
return null;
}
}
private function validateToken($token)
{
if (!$this->hasUser()) {
throw new \Exception('No user set');
}
if(!method_exists($token, 'claims')) {
throw new \Exception('Invalid token');
}
// Check if token exists, belong to the user and not expired
$recordExists = DB::table('jwt_tokens')
->where([
'unique_id' => $token->claims()->get('jti'),
'user_uuid' => $this->user->uuid
])
->where('expires_at', '>', now())
->exists();
if(!$recordExists) {
throw new \Exception('Invalid token');
}
}
For the other methods, they are similar to those in other authentication guards. You can copy and paste the following code for the complete app/Guards/JwtGuard.php:
<?php
namespace App\Guards;
use Lcobucci\JWT\Configuration;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use App\Helpers\JwtHelper;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class JwtGuard implements Guard
{
/**
* @var User | null $user The user.
* @var UserProvider $provider The user provider.
* @var Request $request The request.
* @var Configuration $config The JWT configuration.
*/
protected $user;
protected UserProvider $provider;
protected Request $request;
protected Configuration $config;
public function __construct(UserProvider $provider, Request $request)
{
// Define the Provide and The Request incoming from the middleware
$this->provider = $provider;
$this->request = $request;
// Define the auth configuration we need in the guard
$this->config = JwtHelper::getJwtConfiguration();
}
// Check if the the user already defined
public function check()
{
return !is_null($this->user());
}
public function guest()
{
return !$this->check();
}
// The process to define the user making the request
public function user()
{
// check if it's already defined to skip the process
if ($this->hasUser()) {
return $this->user;
}
try {
// Check if we have the bearer token with the ncoming Request
$token = $this->request->bearerToken();
if (!$token) {
return null;
}
// If token exists let's parse it, based on our private/public keys
$token = $this->config->parser()->parse($token);
if(!method_exists($token, 'claims')) {
return null;
}
// Check if there are any token constraints
$constraints = $this->config->validationConstraints();
if(!empty($constraints)) {
// If constraints exists let's validate them
$this->config->validator()->assert($token, ...$constraints);
}
// Retrive the user_uuid claim, stop the process if is missing
$uuid = $token->claims()->get('sub');
if(!$uuid) {
return null;
}
// Check if user exists with the uuid above
$user = User::where('uuid', $uuid)->first();
if(!$user) {
return null;
}
// We set the user matching the records and we validate the token
$this->setUser($user);
$this->validateToken($token);
return $this->user;
} catch (\Exception $e) {
return null;
}
}
public function id()
{
return $this->user() ? $this->user()->getAuthIdentifier() : null;
}
/**
* Validate a user's credentials.
*
* @param array<mixed> $credentials User credentials.
* @return bool Always returns false, as this method is not applicable for JWT Guard.
*/
public function validate(array $credentials = []): bool
{
// This method is not applicable for JWT Guard
return false;
}
public function hasUser()
{
return !is_null($this->user);
}
/**
* Set the user.
* @param \App\Models\User $user The user to set.
* @return $this
*/
public function setUser($user)
{
$this->user = $user;
return $this;
}
/**
* Validate the token
* @param \Lcobucci\JWT\Token $token The token to destroy.
* @return void
* @throws \Exception If the token is invalid.
*/
private function validateToken($token)
{
if (!$this->hasUser()) {
throw new \Exception('No user set');
}
if(!method_exists($token, 'claims')) {
throw new \Exception('Invalid token');
}
// Check if token exists, belong to the user and not expired
$recordExists = DB::table('jwt_tokens')
->where([
'unique_id' => $token->claims()->get('jti'),
'user_uuid' => $this->user->uuid
])
->where('expires_at', '>', now())
->exists();
if(!$recordExists) {
throw new \Exception('Invalid token');
}
}
/**
* Attempt to authenticate a user using the given credentials.
* @param array<mixed> $credentials User credentials.
* @param bool $remember Whether to remember the user.
* @return void
* @throws \Exception If the authentication attempt fails.
*/
public function attempt(array $credentials = [], $remember = false)
{
$user = User::where('email', $credentials['email'])->first();
if (!$user || !Hash::check($credentials['password'], $user->password)) {
throw new \Exception(__('auth.failed'));
} else {
$this->setUser($user);
}
}
}
To take our custom authentication into action
The final step now is to define the middleware and make our custom authentication as the default.
Let’s define the middlware. Go to app/Providers/AppServiceProvider.php and add the following code in the boot() method:
Auth::extend('jwt', function (Application $app, string $name, array $config) {
$userProvider = Auth::createUserProvider($config['provider']);
if (!$userProvider) {
// Handle the null case, e.g., throw an exception or provide a default UserProvider
throw new \Exception("User provider cannot be null.");
}
return new JwtGuard(
$userProvider,
$app->make('request')
);
});
Your app/Providers/AppServiceProvider.php must look like this :
<?php
namespace App\Providers;
use App\Guards\JwtGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Foundation\Application;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Register the JWT guard
Auth::extend('jwt', function (Application $app, string $name, array $config) {
$userProvider = Auth::createUserProvider($config['provider']);
if (!$userProvider) {
// Handle the null case, e.g., throw an exception or provide a default UserProvider
throw new \Exception("User provider cannot be null.");
}
return new JwtGuard(
$userProvider,
$app->make('request')
);
});
}
}
Now that we have defined our new authentication guard, let’s set the application to use it by default. Navigate to config/auth.php, where you’ll see that the default guard is “web” if it is not explicitly defined in the .env variables.
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
// ...
]
Let’s add a new guard named “api”. In the same file, if you scroll down slightly, you’ll find the “guards” section. Here, you can add as many guards as needed. Our new guard should look like this:
<?php
return [
// ...
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
// ...
]
You can now just go to your .env file and add AUTH_GUARD=”api”.
Congratulations, you just set your guard :)
Authentication middlware with you custom guard in Action
Now, the most important part is how to secure our HTTP requests using the newly configured guard. To do this, simply apply the auth:api middleware to your routes as follows:
Route::middleware('auth:api')->group(function () {
// Your protected routes
})
Generate private/public keys
You may wonder in what step we can generate the keys in the storage we created before. To make sure we have an out-of-the-box application, we will create a console command to generate is for us.
We will make a prompt console where we will write our passphrase to generate both private and public keys.
First let’s create the console command file
php artisan make:command GenerateTokenKeys
The command will create a file in app/Console/Commands/GenerateTokenKeys.php
Then copy/paste the following code into it :
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class GenerateTokenKeys extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:generate-token-keys';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command to generate public and private keys for JWT.';
/**
* Execute the console command.
*/
public function handle(): void
{
// Ask for the passphrase
$passphrase = $this->secret('Enter the passphrase for the private key');
$passphraseConfirm = $this->secret('Confirm the passphrase for the private key');
if ($passphrase !== $passphraseConfirm) {
$this->error('The passphrases do not match.');
return;
}
// Generate the private key
$privateKey = Storage::path('jwt/private.pem');
$command = "openssl genrsa -passout pass:{$passphrase} -out {$privateKey}";
exec($command);
// Generate the public key
$publicKey = Storage::path('jwt/public.pem');
$command = "openssl rsa -in {$privateKey} -passin pass:{$passphrase} -pubout -out {$publicKey}";
exec($command);
$this->info('Public and private keys have been generated successfully.');
}
}
And voila! We’re good to go.
BONUS
Thank you for reading till this section, as a bonus for you, let’s create a console command that will remove the expired token from jwt_tokens table and schedule it to run everyday.
First, let create console command file. Please run the following command in your console :
php artisan make:command RemoveExpiredTokens
Let’s copy/paste the following code into it:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RemoveExpiredTokens extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:remove-expired-tokens';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command will be scheduled to remove expired tokens.';
/**
* Execute the console command.
*/
public function handle(): void
{
// Get the current date and time
$date = new \DateTimeImmutable();
// Remove expired tokens
DB::table('jwt_tokens')->where('expires_at', '<', $date)->delete();
}
}
Now, we need to let the console executes everyday. Please open the routes/console.php file and add the following line in the end
Schedule::command('app:remove-expired-tokens')->daily();
Conclusion
In conclusion, setting up JWT authentication with asymmetric keys and custom middleware in Laravel 11 enhances security and flexibility for your SaaS applications. This guide walks you through creating a secure folder for key storage, implementing a JwtHelper for configuration, adding UUID support to the User model, and managing tokens with a jwt_tokens table.