Secure Authentication in Laravel 11 with lcobucci/jwt
A step-by-step guide for implementing secure authentication using lcobucci/jwt in Laravel 11, including key generation, database setup, and testing.
This guide is intended to provide a general overview, and the code samples included are designed as templates. It is not recommended to use them directly in a real project without adjustments.
Getting Started
This guide will walk you through implementing a secure JWT-based authentication system in a Laravel 11 application.
JWT (JSON Web Tokens) offers a reliable way to manage authentication and authorization through tokens that can be easily passed between client and server. We’ll cover the full implementation process, including generating RSA keys, managing tokens, setting up necessary configurations, and ensuring security with middleware. By the end of this guide, you’ll have a robust and secure authentication system integrated into your Laravel application.
Setup and Dependencies
To get started, we need to install the necessary packages that will help us implement JWT-based authentication and token management.
lcobucci/jwt
The lcobucci/jwt
library is a powerful and widely-used PHP library for creating, parsing, and validating JWTs. This package makes it easy to handle both symmetric and asymmetric encryption for signing and verifying JWTs. In this guide, we’ll use RSA encryption (asymmetric) for added security, ensuring that tokens are securely signed and verified.
To install lcobucci/jwt
, run:
1
composer require lcobucci/jwt
Key features of lcobucci/jwt
:
- Token Creation: Easily generate JWT tokens with headers, calims, and signature.
- Token Validation: Supports multiple validation methods, including signature, expiration, and custom claims validation.
Asymmetric Encryption: Sign and verify tokens using public and private RSA keys, providing enhanced security.
This library will be the backbone of our JWT-based authentication system, enabling secure token management.
kongulov/interact-with-enum
Enums in PHP (introduced in PHP 8.1) are a great way to define a set of possible values for a variable. The kongulov/interact-with-enum
package provides convenient utilities for interacting with enums in Laravel applications. We’ll use this package to manage token types, such as distinguishing between access and refresh tokens.
Install kongulov/interact-with-enum
by running:
1
composer require kongulov/interact-with-enum
Benefits of kongulov/interact-with-enum
:
- Flexible Enum Handling: Provides an easy way to convert enums to strings and vice versa, making it simpler to handle enum values in database queries or validations.
- Trait-based Utility: It introduces a trait (
InteractWithEnum
) that allows enums to be more dynamic and manageable within your application.
By using this package, we’ll streamline the handling of JWT token types, ensuring that our token-based authentication system is both maintainable and extensible.
Generating Keys for Asymmetric Encryption
In JWT-based authentication, asymmetric encryption (RSA) is commonly used to sign and verify tokens. This approach involves the creation of two keys:
- Private key: Used to sign the token.
- Public key: Used to verify token.
The following steps outline how to generate these RSA keys and configure them for use in your Laravel application.
Step 1: Create a Directory for JWT Keys
First, ensure that a secure directory for storing the JWT keys exists in your storage
folder. These keys will be securely stored in the storage/jwt
directory.
1
mkdir -p storage/jwt
Step 2: Generate the Private Key
The private key is used to sign JWT tokens, ensuring they are securely issued by the server. To generate a 2048-bit RSA private key, use the following command:
1
openssl genpkey -algorithm RSA -out storage/jwt/private_key.pem -pkeyout rsa_keygen_bits:2048
This command generates a private key and stores it in the storage/jwt/private_key.pem
file.
Step 3: Generate the Public Key
Once the private key is generated, you need to generate the corresponding public key. The public key will be used to verify the JWT’s signature.
Use the following command to generate the public key:
1
openssl rsa -pubout -in storage/jwt/private_key.pem -out storage/jwt/public_key.pem
This command reads the private key and generates a public key, storing it in the storage/jwt/public_key.pem
file.
Step 4: Set Permissions for the Private Key
For security reasons, it is important to set appropriate file permissions for the private key to prevent unauthorized access. Run the following command to set the permissions for the private key:
1
chmod 0644 storage/jwt/private_key.pem
This ensures that only the owner can modify the private key, while others can read it if needed.
Step 5: Update the .env File with Key Paths
Next, you need to update your .env
file with the pats to the private and public keys. These keys will be referenced in your application to sign and verify JWTs.
Add the following lines to your .env file:
JWT_PRIVATE_KEY=/full/path/to/storage/jwt/private_key.pem
JWT_PUBLIC_KEY=/full/path/to/storage/jwt/public_key.pem
Step 6: Create a JWT Configuration File
In addition to updating the .env
file, it’s important to create a configuration file to manage JWT-related settings. This file will store paths to the keys and other JWT-specific settings such as token lifespan.
Create a jwt.php
file in the config
directory with the following content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
return [
'public_key_path' => realpath(env("JWT_PUBLIC_KEY", storage_path("jwt/public_key.pem"))),
'private_key_path' => realpath(env("JWT_PRIVATE_KEY", storage_path("jwt/private_key.pem"))),
'access' => [
'ttl' => 30, // Time to live for access tokens (in minutes)
'cbu' => 0, // Can be used after (in minutes)
],
'refresh' => [
'ttl' => 21600, // Time to live for refresh tokens (in minutes - 15 days)
'cbu' => 0, // Can be used after (in minutes)
]
];
This configuration file allows you to define:
- The paths to the private and public keys.
- Token lifespan (TTL) for access and refresh tokens, measured in minutes.
- How long after issuance the token can be used (CBU: “can be used after”).
Step 7: Protect JWT Keys from Version Control
To ensure that your JWT keys are not accidentally committed to version control, you should add a .gitignore
file to the storage/jwt
directory. This ensures that the keys are not tracked by Git, which helps maintain security across different environments.
Create a .gitignore
file in the storage/jwt
folder with the following content:
1
2
*
!.gitignore
This rule will ignore all files in the jwt
directory except for the .gitignore
file itself. This ensures that your keys are safe from version control, while still allowing the directory structure to be maintained.
Conclusion
By generating RSA keys and configuring your Laravel application to use them, you set the foundation for secure JWT-based authentication. These keys ensure that tokens are securely signed and verified using asymmetric encryption, enhancing the overall security of your application.
Database Setup
First, we need two separate tables (migrations):
- jwt_tokens: We will store our JWT tokens and their related details in this table.
- jwt_token_blacklist: We will keep records for blacklisted tokens in this table.
Creating Migrations
Create these migrations using artisan
:
1
2
php artisan make:migration create_jwt_tokens_table
php artisan make:migration create_jwt_token_blacklist_table
jwt_tokens table
Let’s fill out our first migration file as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?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->uuid('id')->primary();
$table->morphs('tokenable');
$table->json('token');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jwt_tokens');
}
};
In this migration, we have four columns:
In this migration, we have four columns:
- uuid(‘id’): We define a unique key for each JWT. Using UUID allows us to directly use it for the jti (JWT ID) claim found in the JWT’s payload without handling an additional value.
- morphs(‘tokenable’): More information about polymorphic relationships can be found here. We use a polymorphic relationship to easily extend this implementation for other models in the future if needed.
- json(‘token’): We will store the unencrypted form of our tokens here.
- timestamps(): We add the general and necessary timestamp columns for our table.
jwt_token_blacklist table
Let’s fill out our second migration file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?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_token_blacklist', function (Blueprint $table) {
$table->id();
$table->uuid('jwt_token_id');
$table->timestamps();
$table->foreign('jwt_token_id')->references('id')->on('jwt_tokens');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jwt_token_blacklist');
}
};
In this table, we have three columns:
- id(): We define our primary key of big integer type.
- uuid(‘jwt_token_id’): We create and relate a column of the same type as the primary key present in the first table.
- timestamps(): As in other tables, we add our general time-related columns here.
Adding TokenTypeEnum
We will also define the TokenType
enum to distinguish between access and refresh tokens. The TokenType
enum is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
namespace App\Enums\JWT;
use Kongulov\Traits\InteractWithEnum;
enum TokenType: string
{
use InteractWithEnum;
case ACCESS = 'access';
case REFRESH = 'refresh';
}
This enum defines two types of tokens:
- ACCESS: Represents access tokens.
- REFRESH: Represents refresh tokens.
The InteractWithEnum
trait, provided by the kongulov/interact-with-enum
Laravel package, allows for easy interaction with enum values throughout the application. This package simplifies working with enums, making it easier to convert them to strings or use them in various parts of your application.
Running Migrations
After creating the migrations, run the following command to apply them to the database:
1
php artisan migrate
Creating Models
Now that our migrations are ready, we can write the models that refer to them.
Token Model
First, let’s create the Token
model that refers to the jwt_tokens
table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php
namespace App\Models\JWT;
use App\Casts\TokenCast;
use App\Contracts\JWT\TokenServiceInterface;
use App\Enums\JWT\TokenType;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
class Token extends Model
{
use HasFactory;
protected $table = 'jwt_tokens';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'id',
'token',
'tokenable_id',
'tokenable_type'
];
protected $hidden = [
'tokenable_id',
'tokenable_type'
];
protected function casts(): array
{
return [
'token' => TokenCast::class,
];
}
public function isAccessToken(): bool
{
return $this->tokenIs(TokenType::ACCESS);
}
public function isRefreshToken(): bool
{
return $this->tokenIs(TokenType::REFRESH);
}
public function tokenIs(TokenType $type): bool
{
return $this->token->claims()->get('typ') === $type->value;
}
public function isRevoked(): HasOne
{
return $this->hasOne(TokenBlacklist::class, 'jwt_token_id', 'id');
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
protected function encrypt(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => app()->get(TokenServiceInterface::class)->encrypt(
$attributes['id'],
$attributes['tokenable_id'],
Carbon::parse($attributes['created_at']),
Carbon::parse($attributes['expires_at']),
Carbon::parse($attributes['can_be_used_after'])
)
);
}
}
According to the current implementation, each
Token
model simultaneously refers to anUnencryptedToken
.
Explanation of Model Properties:
- $table: Specifies the table the model will reference, which is
jwt_tokens
in our case. - $keyType:
Eloquent
assumes theprimary key
is aninteger
by default. We specify it’s astring
. - $incrementing: Since our primary key isn’t an auto-incrementing
integer
, we set this tofalse
. - $fillable: Specifies the attributes that are mass assignable.
- $hidden: Attributes that should be hidden for arrays and JSON serialization.
- $casts: Specifies the attributes that should be cast to native types or custom casts.
Explanation of Model Methods:
- isAccessToken(): Checks if the token is an
access
token. - isRefreshToken(): Checks if the token is a
refresh
token. - tokenIs(TokenType $type): Checks if the token is of a specified type.
- isRevoked(): Defines a
relationship
with theTokenBlacklist
model to check if the token is revoked.
TokenBlacklist Model
Create the TokenBlacklist
model that refers to the jwt_token_blacklist
table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace App\Models\JWT;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TokenBlacklist extends Model
{
use HasFactory;
protected $table = 'jwt_token_blacklist';
protected $fillable = [
'jwt_token_id'
];
}
Custom Mutator Class: TokenCast
In our Token
model, we defined a custom cast for the token
attribute. Now, let’s create the TokenCast
class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php
namespace App\Casts;
use App\Contracts\JWT\TokenServiceInterface;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use JsonException;
use Lcobucci\JWT\Token\RegisteredClaims;
use Lcobucci\JWT\UnencryptedToken;
class TokenCast implements CastsAttributes
{
protected TokenServiceInterface $service;
public function __construct()
{
$this->service = app()->get(TokenServiceInterface::class);
}
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
* @throws JsonException
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
$decoded = json_decode(
json: $value,
associative: true,
flags: JSON_THROW_ON_ERROR
);
$claims = collect($decoded['claims'])->map(function($value, $claim) {
if (in_array($claim, RegisteredClaims::DATE_CLAIMS)) {
return Carbon::parse($value['date'], $value['timezone'])->toDateTimeImmutable();
}
return $value;
});
$token = $this->service->build($claims);
return $token;
}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
* @throws JsonException
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
/** @var UnencryptedToken $value */
$components = [
'headers' => $value->headers()->all(),
'claims' => $value->claims()->all(),
];
$encoded = json_encode(
$components,
JSON_THROW_ON_ERROR
);
return $encoded;
}
}
Explanation:
With this custom cast, we handle the token
attribute’s transformation when retrieving from or storing to the database:
- set method: Takes an
UnencryptedToken
instance and stores it in JSON format. - get method: Converts the JSON data retrieved from the database back into an
UnencryptedToken
instance.
Conclusion
At this point, we have completed our database setup and models. We can now proceed to create service classes and other necessary components for generating and verifying JWTs.
Creating the TokenService
The TokenService
is responsible for generating, signing, verifying, and revoking JWT tokens in your Laravel application. This service will interact with Token
model and use asymmetric encryption (RSA) to secure the tokens.
In this section, we will walk through the TokenService
class and its role in managing JWT-based authentication.
TokenServiceInterface
The TokenServiceInterface
defines the contract for the token service. This ensures that any implementation of the service will have the following core methods:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
namespace App\Contracts\JWT;
use App\Models\JWT\Token;
use Illuminate\Support\Collection;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\UnencryptedToken;
interface TokenServiceInterface
{
/**
* Revoke one or more tokens.
*
* @param Token|Collection $token
* @return void
*/
public function revoke(Token|Collection $token): void;
/**
* Get the JWT configuration, including signer and key paths.
*
* @return Configuration
*/
public function config(): Configuration;
/**
* Generate JWT claims based on the subject.
*
* @param string $sub
* @return Collection
*/
public function data(string $sub): Collection;
/**
* Build and return the signed JWT.
*
* @param Collection $claims
* @return UnencryptedToken
*/
public function build(Collection $claims): UnencryptedToken;
}
This interface ensures that any token service implementation provides the ability to revoke tokens, configure signing and encryption, generate token claims, and create new tokens.
TokenService Implementation
The TokenService
class implements the TokenServiceInterface
and provides concrete logic for handling JWT tokens. Below are the key methods of the service:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<?php
namespace App\Services;
use App\Contracts\JWT\TokenServiceInterface;
use App\Enums\JWT\TokenType;
use App\Models\JWT\Token;
use App\Models\JWT\TokenBlacklist;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha512;
use Lcobucci\JWT\Token\RegisteredClaims;
use Lcobucci\JWT\UnencryptedToken;
class TokenService implements TokenServiceInterface
{
/**
* Get JWT configuration.
*/
public function config(): Configuration
{
$privateKey = config('jwt.private_key_path');
$publicKey = config('jwt.public_key_path');
return Configuration::forAsymmetricSigner(
new Sha512(),
InMemory::file($privateKey),
InMemory::file($publicKey),
);
}
/**
* Build and sign a new JWT token.
*
* @throws Exception
*/
public function build(Collection $claims): UnencryptedToken
{
$config = $this->config();
$builder = $config->builder();
// Map claims to their corresponding builder methods
$methods = [
RegisteredClaims::ID => 'identifiedBy',
RegisteredClaims::SUBJECT => 'relatedTo',
RegisteredClaims::ISSUER => 'issuedBy',
RegisteredClaims::ISSUED_AT => 'issuedAt',
RegisteredClaims::EXPIRATION_TIME => 'expiresAt',
RegisteredClaims::NOT_BEFORE => 'canOnlyBeUsedAfter',
];
$claims->each(function($value, string $name) use (&$builder, $methods) {
$builder = array_key_exists($name, $methods)
? $builder->{$methods[$name]}($value)
: $builder->withClaim($name, $value);
});
return $builder->getToken(
$config->signer(),
$config->signingKey()
);
}
/**
* Generate claims for access and refresh tokens.
*/
public function data(string $sub): Collection
{
$time = now();
$iss = config('app.url');
$grp = Str::uuid()->toString();
// Generate claims for both access and refresh tokens
$payloads = collect(TokenType::cases())->map(function (TokenType $type) use ($sub, $time, $iss, $grp) {
$jti = Str::uuid()->toString();
return collect([
RegisteredClaims::ID => $jti,
'grp' => $grp,
'typ' => $type->value,
RegisteredClaims::SUBJECT => $sub,
RegisteredClaims::ISSUER => $iss,
RegisteredClaims::ISSUED_AT => $time->toDateTimeImmutable(),
RegisteredClaims::EXPIRATION_TIME => $time->copy()->addMinutes(config("jwt.{$type->value}.ttl"))->toDateTimeImmutable(),
RegisteredClaims::NOT_BEFORE => $time->copy()->addMinutes(config("jwt.{$type->value}.cbu"))->toDateTimeImmutable(),
]);
});
return $payloads;
}
/**
* Revoke a token or a collection of tokens.
*
* @param Token|Collection $token
* @return void
*/
public function revoke(Token|Collection $token): void
{
$collection = ($token instanceof Token) ? collect([$token]) : $token;
// Insert tokens into the blacklist
$collection = $collection->map(fn (Token $token) => ['jwt_token_id' => $token->id]);
TokenBlacklist::insert($collection->toArray());
}
}
Key Methods and Responsibilities
- config(): Retrieves the JWT configuration, including the signer and paths to the private and public keys. These keys are used for signing and verifying tokens.
- build(): This method generates and signs a JWT token based on the provided claims. The claims include standard claims like the subject, issuer, and expiration time, along with any custom claims.
- data(): Creates claims for both
access
andrefresh
tokens, usingTokenType
to differentiate between the two. The expiration time (TTL) and the time before the token can be used (CBU) are also configured here. - revoke(): Revokes one or more tokens by adding them to the
TokenBlacklist
. This prevents future use of any token added to the blacklist.
TokenServiceProvider
The TokenServiceProvider
registers the TokenService
in the service container. It also defines a custom JWT-based guard using Laravel’s authentication system.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
namespace App\Providers;
use App\Contracts\JWT\TokenServiceInterface;
use App\Services\Auth\Guard\TokenGuard;
use App\Services\TokenService;
use Illuminate\Auth\RequestGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
class TokenServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
// Bind the TokenServiceInterface to the TokenService implementation
$this->app->bind(TokenServiceInterface::class, TokenService::class);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Extend Laravel's Auth system with the custom JWT-based guard
Auth::extend('jwt', function ($app, $name, array $config) {
return new RequestGuard(
new TokenGuard(app()->get(TokenServiceInterface::class)),
request(),
Auth::createUserProvider($config['provider'])
);
});
}
}
This provider ensures that the TokenService
is available for dependency injection throughout the application, and it sets up the custom JWT guard for authentication.
Declaring the Provider in the Application
Once the TokenServiceProvider
is defined, you need to declare it within the application so Laravel recognizes and loads it. To do this, add the provider to the bootstrap/providers.php
file, which contains the list of service providers that will be registered when the application starts.
Open the boostrap/providers.php
file and add the following entry:
1
2
3
4
5
6
7
8
9
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\TokenServiceProvider::class, // Add this line
App\Providers\TelescopeServiceProvider::class,
L5Swagger\L5SwaggerServiceProvider::class
];
Configuring the Authentication Guard in config/auth.php
You also need to update the config/auth.php
file to define the JWT-based guard. This guard will handle authentication using the tokens generated and verified by the TokenService
.
Update the guards
section in config/auth.php
as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
return [
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt', // Use the custom JWT driver
'provider' => 'users', // Use the 'users' provider
]
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
],
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
In the guards
section, the api
guard is now configured to use the jwt
driver, which is provided by the TokenService
we defined earlier.
Conclusion
The Token Service plays a crucial role in managing JWT-based authentication, providing functionality to generate, sign, verify, and revoke tokens. With the use of asymmetric encryption (RSA), the service ensures secure token issuance and validation. Additionally, the custom JWT guard allows seamless integration with Laravel’s authentication system.
Configure API Gate and Related Files
In this section, we will configure the API Gate for managing JWT-based authentication in Laravel. The API gate is responsible for intercepting incoming API requests, validating JWT tokens, and authenticating users based on the token’s claims.
Creating The TokenGuard Class
The TokenGuard
class is the key component that validates incoming requests and ensures that they contain a valid JWT. This guard will handle extracting the JWT from the request, validating the token, and identifying the user from the token’s claims.
Here is the TokenGuard
class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
namespace App\Services\Auth\Guard;
use App\Traits\TokenValidation;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Request;
use Throwable;
class TokenGuard
{
use GuardHelpers;
use TokenValidation;
/**
* Handle the incoming request and authenticate the user based on JWT token.
*
* @param Request $request
* @param UserProvider $provider
* @return Authenticatable|null
*
* @throws AuthenticationException
* @throws AuthorizationException
* @throws Throwable
*/
public function __invoke(Request $request, UserProvider $provider): ?Authenticatable
{
// Retrieve and validate the JWT from the request
throw_if(! $token = $this->getTokenForRequest($request), AuthenticationException::class);
// Retrieve the user based on the token's "sub" (subject) claim
throw_if(! $tokenable = $provider->retrieveById($token->claims()->get('sub')), AuthenticationException::class);
// Set the authenticated user
return $this->user = $tokenable;
}
}
TokenValidation Trait
The TokenValidation
trait is essential for extracting and validating JWT tokens from incoming requests. It handles retrieving tokens from various parts of the request (headers, query parameters, etc.) and verifying the token’s signature, expiration, and other claims.
Here is the TokenValidation
trait:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
namespace App\Traits;
use App\Models\JWT\Token;
use App\Contracts\JWT\TokenServiceInterface;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Exception;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha512;
use Lcobucci\JWT\UnencryptedToken;
use Throwable;
trait TokenValidation
{
protected TokenServiceInterface $service;
protected Configuration $config;
protected string $inputKey;
public function __construct(
?TokenServiceInterface $service = null,
string $inputKey = 'token',
) {
$this->service = $service ?? app()->get(TokenServiceInterface::class);
$this->config = $this->service->config();
$this->inputKey = $inputKey;
}
/**
* Get the token for the current request.
*
* @param Request $request
* @return UnencryptedToken|null
*/
private function getTokenForRequest(Request $request): ?UnencryptedToken
{
$token = $request->query($this->inputKey);
if (empty($token)) {
$token = $request->input($this->inputKey);
}
if (empty($token)) {
$token = $request->cookie($this->inputKey);
}
if (empty($token)) {
$token = $request->bearerToken() ?: null;
}
if ($token) {
try {
$token = $this->config->parser()->parse($token);
} catch (Exception) {
$token = null;
}
}
return $token;
}
/**
* Verify the validity of a JWT token.
*
* @throws AuthorizationException
*/
private function validate(UnencryptedToken $token, Token $record): void
{
// Token validation logic here, including checking signature, expiration, and revocation
}
}
Explanation:
- getTokenForRequest(): Retrieves the JWT token from the request (query, input, cookie, or bearer token).
- isRevoked(): Checks if the token is revoked by querying the database.
- verify(): Uses JWT constraints to validate the token (signature, related user, expiration).
- validate(): Verifies the token and throws an exception if it is revoked or invalid.
This trait plays a crucial role in handling the common logic for retrieving and validating JWT tokens, used by both the TokenGuard
and other middleware classes.
Defining API Routes and Implementing Controllers
Once the TokenGuard
is set up, we can define API routes that are protected by JWT authentication. The following routes will allow user registration, authentication, retrieving user details, token revocation, and token refresh.
In routes/api.php
, we will define the following routes for user authentication and token management:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
use App\Http\Controllers\API\V1\AuthController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->middleware('localization')->group(function () {
Route::prefix('auth')->controller(AuthController::class)->name('auth.')->group(function () {
Route::post('/authenticate', 'authenticate')->name('authenticate');
Route::post('/register', 'register')->name('register');
// Routes protected by access token
Route::middleware(['jwt.access', 'auth:api'])->group(function () {
Route::get('me', 'me')->name('me'); // Fetch authenticated user details
Route::delete('revoke', 'revoke')->name('revoke'); // Revoke the user's token
});
// Route protected by refresh token
Route::middleware(['jwt.refresh', 'auth:api'])->group(function () {
Route::post('refresh', 'refresh')->name('refresh'); // Refresh access token
});
});
});
Route Explanation
- Localization Middleware: The routes are prefixed with v1 and use the
localization
middleware to handle language settings. - Authentication Endpoints:
/authenticate
: Allows users to authenticate with their credentials and receive an access token./register
: Allows new users to register an account.
- Protected Routes:
- Access Token Protected Routes: These routes require a valid access token (
jwt.access
) and authenticate the user with theauth:api
middleware.GET /me
: Fetch the authenticated user’s details.DELETE /revoke
: Revoke the user’s current token, effectively logging them out.
- Refresh Token Protected Routes: This route requires a valid refresh token (
jwt.refresh
) and is also authenticated withauth:api
.POST /refresh
: Refresh the access token using the refresh token.
- Access Token Protected Routes: These routes require a valid access token (
Implementing Controllers
Nex, we will implement the AuthController
to handle the logic behind these routes. The `AuthController will manage registration, authentication, token revocation, and token refreshing, ensuring secure interaction with JWT tokens.
Here is the full implementation of the AuthController
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
<?php
namespace App\Http\Controllers\API\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\TokenResource;
use App\Http\Resources\UserResource;
use App\Models\JWT\Token;
use App\Models\User;
use App\Repositories\TokenRepository;
use App\Repositories\UserRepository;
use App\Traits\TokenValidation;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class AuthController extends Controller
{
use TokenValidation;
/**
* Register a new user.
*
* @param Request $request
* @param UserRepository $repository
* @return JsonResponse
*/
public function register(Request $request, UserRepository $repository): JsonResponse
{
$validated = $request->validate([
'name' => 'required',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'passwordConfirm' => 'required|same:password',
]);
$user = $repository->create($validated);
$response = $this->userResponse($user, Response::HTTP_CREATED);
return $response;
}
/**
* Authenticate a user and return a JWT token.
*
* @param Request $request
* @param TokenRepository $repository
* @return JsonResponse
* @throws Throwable
*/
public function authenticate(Request $request, TokenRepository $repository): JsonResponse
{
list($email, $password) = array_values($request->validate([
'email' => 'required|email',
'password' => 'required',
]));
$user = User::where('email', $email)->first();
throw_if(
! $user || ! Hash::check($password, $user->password),
AuthenticationException::class,
'Unauthenticated.'
);
$response = $this->userResponse($repository->create([$user]), Response::HTTP_OK);
return $response;
}
/**
* Revoke the current JWT token.
*
* @param Request $request
* @return JsonResponse
*/
public function revoke(Request $request): JsonResponse
{
$token = $this->getTokenForRequest($request);
$this->service->revoke(Token::whereJsonContains('token->claims->grp', $token->claims()->get('grp'))->get());
return response()->json([], Response::HTTP_NO_CONTENT);
}
/**
* Refresh the JWT token.
*
* @param TokenRepository $repository
* @return JsonResponse
*/
public function refresh(TokenRepository $repository): JsonResponse
{
$user = auth('api')->user();
$tokens = $repository->create([$user])->new_tokens;
$response = new JsonResponse([
'new_tokens' => TokenResource::collection($tokens)
], Response::HTTP_CREATED);
return $response;
}
/**
* Get the authenticated user's details.
*
* @return JsonResponse
*/
public function me(): JsonResponse
{
$user = auth('api')->user();
$response = $this->userResponse($user);
return $response;
}
/**
* Generate a user response with the given resource.
*
* @param Authenticatable|null $record
* @param int $status
* @return JsonResponse
*/
private function userResponse(?Authenticatable $record, int $status = 200): JsonResponse
{
$resource = new UserResource($record);
$response = new JsonResponse($resource, $status);
return $response;
}
}
Resource Classes for Consistent Responses
To ensure consistency in the API responses, we will use Laravel’s Resource Classes. These classes transform data into a JSON structure, allowing for easy formatting and customization of the response.
UserResource Class
The UserResource
class formats the authenticated user’s data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return array_merge(parent::toArray($request), [
'new_tokens' => $this->whenHas('new_tokens', fn () => TokenResource::collection($this->new_tokens))
]);
}
}
TokenResource Class
The TokenResource
class formats the JWT token’s headers and claims:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
namespace App\Http\Resources;
use App\Contracts\JWT\TokenServiceInterface;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Lcobucci\JWT\UnencryptedToken;
class TokenResource extends JsonResource
{
protected TokenServiceInterface $service;
public function __construct($resource)
{
parent::__construct($resource);
$this->service = app()->get(TokenServiceInterface::class);
}
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
* @throws Exception
*/
public function toArray(Request $request): array
{
/** @var UnencryptedToken $token */
$token = $this->token;
return [
'headers' => $token->headers()->all(),
'claims' => $token->claims()->all(),
'token' => $token->toString()
];
}
}
Implementing Repositories
To handle database interactions, we will use repositories. Repositories provide a clean abstraction layer between our controllers and the database, making the code more maintenable and testable.
Abstract Repository
The AbstractRepository
class provides the basic CRUD operations that can be reused across different models:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
namespace App\Repositories;
use Illuminate\Database\Eloquent\Model;
abstract class AbstractRepository
{
protected string $model;
public function __construct()
{
}
public function create(array $data): Model
{
return $this->model::create($data);
}
public function update(Model $model, array $data): bool
{
return $model->update($data);
}
public function destroy(Model $model): bool
{
return $model->delete();
}
}
This abstract class defines three primary methods:
- create: To insert new records into the database.
- update: To update existing records.
- destroy: To delete a record from the database.
This can be extended by concrete repository classes to implement model-specific logic.
User Repository
The UserRepository
is responsible for handling user-related database operations. It extends the AbstractRepository and uses the
TokenServiceInterface` to generate tokens for newly created users.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php
namespace App\Repositories;
use App\Contracts\JWT\TokenServiceInterface;
use App\Models\User;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Lcobucci\JWT\UnencryptedToken;
class UserRepository extends AbstractRepository
{
protected string $model = User::class;
public function __construct(
protected TokenServiceInterface $service
)
{
parent::__construct();
}
public function create(array $data): Authenticatable
{
$user = DB::transaction(function () use ($data) {
/** @var User $user */
$user = parent::create($data);
$tokenPayloads = $this->service->data(sub: $user->id);
/** @var Collection<UnencryptedToken> $tokens */
$tokens = $tokenPayloads->map(fn ($payload) => $this->service->build($payload));
/** @var Collection<array> $tokenData */
$tokenData = $tokens->map(function(UnencryptedToken $token) use ($user) {
return [
'id' => $token->claims()->get('jti'),
'token' => $token,
'tokenable_id' => $user->id,
'tokenable_type' => get_class($user),
];
});
$user->tokens()->createMany($tokenData);
$user->setAttribute(
'new_tokens',
$tokenData->map(fn ($token) => new Token($token))
);
return $user;
});
return $user;
}
}
Explanation:
- The
create
method creates a new user and generates JWT tokens for the user. It uses theTokenServiceInterface
to generate the tokens and saves them to the database.
Token Repository
The TokenRepository
handles token-related operations, including generating and storing tokens for users.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php
namespace App\Repositories;
use App\Contracts\JWT\TokenServiceInterface;
use App\Models\JWT\Token;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Lcobucci\JWT\UnencryptedToken;
class TokenRepository extends AbstractRepository
{
protected string $model = Token::class;
public function __construct(protected TokenServiceInterface $service)
{
parent::__construct();
}
public function create(array $data): Authenticatable
{
$user = DB::transaction(function () use ($data) {
$user = current($data);
$tokenPayloads = $this->service->data(sub: $user->id);
/** @var Collection<UnencryptedToken> $tokens */
$tokens = $tokenPayloads->map(fn ($payload) => $this->service->build($payload));
/** @var Collection<array> $tokenData */
$tokenData = $tokens->map(function(UnencryptedToken $token) use ($user) {
return [
'id' => $token->claims()->get('jti'),
'token' => $token,
'tokenable_id' => $user->id,
'tokenable_type' => get_class($user),
];
});
$user->tokens()->createMany($tokenData);
$user->setAttribute(
'new_tokens',
$tokenData->map(fn ($token) => new Token($token))
);
return $user;
});
return $user;
}
}
Explanation:
- The
create
method inTokenRepository
generates new JWT tokens for the authenticated user. Similar to theUserRepository
, it uses theTokenServiceInterface
to generate tokens and stores them in thejwt_tokens
table.
Conclusion
In this section, we successfully configured the API gate to manage JWT-based authentication in Laravel. By implementing the TokenGuard
class and utilizing the TokenValidation trait, we ensured that JWT tokens are properly extracted, validated, and authenticated before allowing access to protected routes. We defined key API routes for user registration, authentication, and token management, while ensuring that routes are protected using the appropriate JWT tokens — access tokens for immediate requests and refresh tokens for extending session lifetimes.
With the AuthController
handling the core authentication logic, and Resource Classes ensuring consistent responses, this setup provides a robust and flexible foundation for secure token-based authentication. The middleware for handling both access and refresh tokens allows seamless management of token verification, adding an additional layer of security to your Laravel API.
This setup now enables a secure, well-structured authentication system based on JWT, ensuring that sensitive API endpoints are only accessible to authorized users with valid tokens.
Middlewares
After setting up our database models, the next step is to implement middleware for handling the JWT tokens in incoming HTTP requests. We will create two middlewares:
- AccessTokenMiddleware: To verify and process access tokens.
- RefreshTokenMiddleware: To verify and process refresh tokens.
Creating Middlewares:
We can use artisan
to create these middlewares:
1
2
php artisan make:middleware JWT\AccessTokenMiddleware
php artisan make:middleware JWT\RefreshTokenMiddleware
Middleware Structure
We will define a base abstract middleware class called TokenAbstractMiddleware
that will handle the common logic for token validation. The TokenValidation
trait, which was explained earlier, will be used to manage the core logic for extracting and validating tokens.
TokenAbstractMiddleware
The TokenAbstractMiddleware
class provides the basic structure for handling token validation in Laravel:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?php
namespace App\Http\Middleware\JWT;
use App\Enums\JWT\TokenType;
use App\Models\JWT\Token;
use App\Traits\TokenValidation;
use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
abstract class TokenAbstractMiddleware
{
use TokenValidation;
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure(Request): (Response) $next
* @return Response
* @throws Throwable
*/
abstract public function handle(Request $request, Closure $next): Response;
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Throwable
*/
protected function start(Request $request, TokenType $type): void
{
$token = $this->getTokenForRequest($request);
throw_if(
! $record = Token::where('id', $token?->claims()->get('jti'))->first(),
AuthenticationException::class
);
throw_if(
! $record->tokenIs($type),
AuthorizationException::class
);
$this->validate($token, $record);
}
}
Explanation:
- start(): The method is responsible for extracting the JWT from the request, validating it, and ensuring it is the correct type (either access or refresh token). If the token is not found or is invalid, exception are thrown, which will be caught by the Laravel error-handling system.
- TokenValidation: This trait contains shared logic for token validation e.g, signature verification and claims validation).
AccessTokenMiddleware
The AccessTokenMiddleware
extends TokenAbstractMiddleware
and verifies that the incoming request contains a valid access token.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
namespace App\Http\Middleware\JWT;
use App\Enums\JWT\TokenType;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class AccessTokenMiddleware extends TokenAbstractMiddleware
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure(Request): (Response) $next
* @return Response
* @throws Throwable
*/
public function handle(Request $request, Closure $next): Response
{
$this->start($request, TokenType::ACCESS);
return $next($request);
}
}
RefreshTokenMiddleware
Similarly, the RefreshTokenMiddleware
verifies that the incoming request contains a valid refresh token.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
namespace App\Http\Middleware\JWT;
use App\Enums\JWT\TokenType;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class RefreshTokenMiddleware extends TokenAbstractMiddleware
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure(Request): (Response) $next
* @return Response
* @throws Throwable
*/
public function handle(Request $request, Closure $next): Response
{
$this->start($request, TokenType::REFRESH);
return $next($request);
}
}
LocalizationMiddleware
The LocalizationMiddleware
is responsible for managing localization settings based on user preferences or request headers. This middleware sets the application’s locale dynamically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class LocalizationMiddleware
{
public function handle(Request $request, Closure $next)
{
$locale = $request->header('Accept-Language', config('app.locale'));
app()->setLocale($locale);
return $next($request);
}
}
Registering the Middleware
In Laravel 11, middleware registration is handled in the bootstrap/app.php
file, allowing you to register middleware aliases and define middleware execution priorities in a more flexible manner.
To register middleware in Laravel 11, follow this structure in the bootstrap/app.php
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Registering middleware aliases
$middleware->alias([
'localization' => \App\Http\Middleware\LocalizationMiddleware::class,
'jwt.access' => \App\Http\Middleware\JWT\AccessTokenMiddleware::class,
'jwt.refresh' => \App\Http\Middleware\JWT\RefreshTokenMiddleware::class,
]);
// Defining middleware priority
$middleware->priority([
\App\Http\Middleware\LocalizationMiddleware::class,
\App\Http\Middleware\JWT\AccessTokenMiddleware::class,
\App\Http\Middleware\JWT\RefreshTokenMiddleware::class,
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
\Illuminate\Auth\Middleware\Authorize::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
// Handle global exception configuration (if needed)
})
->create();
This new approach cerntalizes middleware registration and management in the bootstrap/app.php
file, streamlining the configuration and ensuring more control over middleware execution in your Laravel 11 application.
Conclusion
With the middleware we have implemented, we now have a structure in place to protect our API endpoints using JWT tokens. The AccessTokenMiddleware
and RefreshTokenMiddleware
handle the validation of access and refresh tokens, while the LocalizationMiddleware
sets the applications’ language dynamically based on the users’ preferences.
- AccessTokenMiddleware: This middleware ensures that only requests with valid access tokens are accepted. For instance, routes like
me
that retrieve user data will only work with a valid access token. - RefreshTokenMiddleware: Routes that rely on refresh tokens, like the route to renew an access token, are protected by this middleware. It ensures that only valid refresh tokens are accepted.
- LocalizationMiddleware: It reads the
Accept-Language
header from the request and sets the application’s locale accordingly. - With these middleware in place, you can now protect routes based on the type of token. For example, to secure a route that requires an access token:
1
2
3
4
Route::middleware('jwt.access')->group(function() {
Route::get('/me', [UserController::class, 'me']);
});
And for a route that requires a refresh token, such as refreshing an access token:
1
2
3
4
Route::middleware('jwt.refresh')->group(function() {
Route::post('/refresh', [AuthController::class, 'refresh']);
});
This setup ensures that only users with valid JWT tokens can access the protected resources. Additionally, by incorporating the LocalizationMiddleware
, your application can handle localization dynamically based on the users’ preferences. Using middleware correctly is a fundamental component in building a secure and flexible API.
Extra
In this section, we will introduce additional features, commands, and testing techniques to further enhance and verify the reliability of our JWT-based authentication system. This includes automatically generating RSA keys, cleaning up expired tokens using Laravel’s command-line utilities, and implementing unit/feature tests to ensure everything is functioning as expected.
Command Line Utilities
This clearly separates the content from testing, emphasizing the use of Laravel’s command-line tools to enhance the JWT authentication system.
Creating Artisan Commands
Laravel’s Artisan command-line tool allows you to quickly scaffold custom commands. To create a new command, use the make:command
Artisan command:
1
2
php artisan make:command GenerateJwtKeys
php artisan make:command ClearExpiredTokens
This will create two new command files inside the app/Console/Commands
directory. You can find the respective files as GenerateJwtKeys
and ClearExpiredTokens.php
. Laravel generates a basic structure, and we can now define the logic for these commands.
JWT Key Generation Command
To automate the process of generating RSA keys, we can create a console command that generates both the private and public keys, sets appropriate file permissions, and updates the .env
file with the correct key paths.
Here’s the source code for the GenerateJwtKeys
command:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Illuminate\Support\Facades\File;
class GenerateJwtKeys extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'jwt:generate-keys';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate JWT RSA keys, set permissions, and update .env file';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
// Ensure the storage/jwt directory exists
$this->createJwtStorageDirectory();
// Step 1: Generate the private key
$this->generatePrivateKey();
// Step 2: Generate the public key
$this->generatePublicKey();
// Step 3: Set group read permissions for the private key
$this->setReadPermissions();
// Step 4: Update the .env file with the real paths of the keys
$this->updateEnvFile();
return Command::SUCCESS;
}
/**
* Ensure the storage/jwt directory exists.
*/
private function createJwtStorageDirectory()
{
if (!File::exists(storage_path('jwt'))) {
File::makeDirectory(storage_path('jwt'), 0755, true);
$this->info('Created storage/jwt directory.');
}
}
/**
* Generate the RSA private key.
*/
private function generatePrivateKey()
{
$process = new Process([
'openssl',
'genpkey',
'-algorithm', 'RSA',
'-out', storage_path('jwt/private_key.pem'),
'-pkeyopt', 'rsa_keygen_bits:2048'
]);
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
$this->info('Private key generated successfully.');
}
/**
* Generate the RSA public key.
*/
private function generatePublicKey()
{
$process = new Process([
'openssl',
'rsa',
'-pubout',
'-in', storage_path('jwt/private_key.pem'),
'-out', storage_path('jwt/public_key.pem')
]);
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
$this->info('Public key generated successfully.');
}
/**
* Set group read permissions for the private key.
*/
private function setReadPermissions()
{
$privateKeyPath = storage_path('jwt/private_key.pem');
// Set the file permissions to allow group read access
chmod($privateKeyPath, 0644);
$this->info('Group read permissions set for private key.');
}
/**
* Update the .env file with the real paths of the private and public keys.
*/
private function updateEnvFile()
{
$privateKeyPath = realpath(storage_path('jwt/private_key.pem'));
$publicKeyPath = realpath(storage_path('jwt/public_key.pem'));
$this->updateEnvVariable('JWT_PRIVATE_KEY', $privateKeyPath);
$this->updateEnvVariable('JWT_PUBLIC_KEY', $publicKeyPath);
$this->info('.env file updated with JWT key paths.');
}
/**
* Update a specific environment variable in the .env file.
*
* @param string $key
* @param string $value
*/
private function updateEnvVariable(string $key, string $value)
{
$envFile = base_path('.env');
$content = file_get_contents($envFile);
if (strpos($content, "$key=") !== false) {
// Update the existing key
$content = preg_replace("/^$key=.*$/m", "$key=$value", $content);
} else {
// Add the key to the end of the file
$content .= "\n$key=$value";
}
file_put_contents($envFile, $content);
}
}
Explanation:
- This command automates the generation of RSA private and public keys.
- It sets appropriate file permissions for the keys and ensures the
.env
file contains the correct paths to the keys. - This command can be executed by running:
1
php artisan jwt:generate-keys
Command to Clear Expired Tokens
To maintain a clean and efficient database, we should regularly remove expired tokens. For this, we will create a console command to delete expired JWT tokens based on their TTL (Time-to-live).
Here is the ClearExpiredTOkens
command:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php
namespace App\Console\Commands;
use App\Enums\JWT\TokenType;
use App\Models\JWT\Token;
use Illuminate\Console\Command;
class ClearExpiredTokens extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:delete-expired-tokens';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete expired tokens from the database';
/**
* Execute the console command.
*/
public function handle(): void
{
$now = now();
// Get the TTL for each token type from the configuration
$tokenTypes = [
TokenType::ACCESS->value => config('jwt.access.ttl'),
TokenType::REFRESH->value => config('jwt.refresh.ttl'),
];
// Prepare a query to delete expired tokens
foreach ($tokenTypes as $type => $ttl) {
$expiryDate = $now->subMinutes($ttl);
// Delete expired tokens in bulk
Token::where('type', $type)
->where('created_at', '<=', $expiryDate)
->delete();
}
}
}
Explanation:
- This command deleted all tokens that have expired based on their TTL configuration.
- You can schedule this command to run periodically using Laravel’s task scheduling feature.
- To run this command manually, execute:
1
php artisan app:delete-expired-tokens
Scheduling the Token Cleanup Command
In Laravel 11, you no longer need to manage cron entries manually. Instead, you define your scheduled tasks inside the routes/console.php
file. This. keeps your task schedules within source control and provides an expressive way to manage scheduling within your Laravel application.
Define Scheduled Tasks
To schedule the ClearExpiredTokens
command, follow these steps:
- Open the
routes/console.php
file. - Use the
Schedule
facade to define the schedule for your command.
Here’s the code to schedule CleareExpiredTokens
command to run daily:
1
2
3
4
5
6
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:delete-expired-tokens')->daily();
Additional Notes
In this approach:
- Schedule::command is used to schedule the
ClearExpiredTokens
Artisan command. - The
daily()
method ensures that this task is run once a day. - You can define other frequencies like
hourly()
,evenryFiveMinutes()
, or even specific time intervals usingcron()
expressions if needed.
Viewing Scheduled Tasks
To see an overview of your scheduled tasks and the next time they are scheduled to run, you can use the following Artisan command:
1
php artisan schedule:list
Conclusion
In Laravel 11, scheduling tasks is more flexible and can be handled directly within the application code. By adding your tasks to routes/console.php
, you centralize task management and eliminate the need for manual cron job setup on you server. With the CleareExpiredTokens
command scheduled to run daily, you ensure your database remains clean and efficient by automatically removing expired tokens.
Testing
Ensuring the reliability and correct functionality of our project is crucial, which is why we need to create comprehensive test scenarios. In this section, we’ll explain in detail how to write unit and functional tests for our JWT-based authentication system.
AuthTestHelpers (Test Helper Trait)
First, we create a trait called AuthTestHelpers
to simplify our testing process and reduce repetitive code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<?php
namespace Tests\Assets\Traits;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Testing\TestResponse;
trait AuthTestHelpers
{
use RefreshDatabase, WithFaker;
protected array $userResponseStructure;
protected array $tokenResponseStructure;
protected array $registeredUserResponseStructure;
protected array $authenticatedUserResponseStructure;
public function setUp(): void
{
parent::setUp();
$this->tokenResponseStructure = [
'headers' => [
'typ',
'alg'
],
'claims' => [
'jti',
'grp',
'typ',
'iat',
'exp',
'nbf'
],
'token'
];
$this->userResponseStructure = [
'id',
'name',
'email',
'updated_at',
'created_at'
];
$this->registeredUserResponseStructure = array_merge($this->userResponseStructure, [
'new_tokens' => [
$this->tokenResponseStructure,
$this->tokenResponseStructure
]
]);
$this->authenticatedUserResponseStructure = $this->registeredUserResponseStructure;
}
/**
* Create a new user and return the response.
*/
protected function registerUser(array $overrides = []): TestResponse
{
$user = array_merge([
'name' => $this->faker->name,
'email' => $this->faker->unique()->safeEmail,
'password' => 'password',
'passwordConfirm' => 'password'
], $overrides);
if (isset($overrides['password'])) {
$user['passwordConfirm'] = $overrides['password'];
}
return $this->post(route('auth.register'), $user, [
'Accept' => 'application/json',
]);
}
/**
* Get the authenticated user's information.
*/
protected function me(?string $token = null, array $overrides = []): TestResponse
{
$user = $this->registerUser($overrides);
$token = $token ?? $user->json('new_tokens.0.token');
return $this->get(route('auth.me'), [
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
]);
}
protected function authenticate(array $register = [], array $authenticate = []): TestResponse
{
$this->registerUser($register);
return $this->post(route('auth.authenticate'), array_merge($register, $authenticate), [
'Accept' => 'application/json',
]);
}
protected function revoke(?string $key = null): TestResponse
{
$registered = $this->registerUser();
$token = $registered->json($key);
return $this->delete(route('auth.revoke'), [], [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
]);
}
protected function refresh(?string $key = null): TestResponse
{
$registered = $this->registerUser();
$token = $registered->json($key);
return $this->post(route('auth.refresh'), [], [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
]);
}
}
This trait provides helper methods for common operations such as user registration, authentication, token refresh, and revocation. It also defines expected JSON response structures. By using this trait, we can keep our test classes clean and focused on specific test scenarios.
Key methods in this trait include:
registerUser()
: Simulates user registration.me()
: Retrieves authenticated user information.authenticate()
: Simulates user login.revoke()
: Simulates token revocation.refresh()
: Simulates token refresh.
AuthControllerTest
The AuthControllerTest
class checks various aspects of the authentication process:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
<?php
namespace Tests\Feature\Http\Controllers\API;
use App\Models\JWT\TokenBlacklist;
use Illuminate\Support\Facades\Config;
use Tests\Assets\Traits\AuthTestHelpers;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use AuthTestHelpers;
/**
* Test that user registration returns a 200 status.
*/
public function test_user_registration_returns_status_201(): void
{
$response = $this->registerUser();
$response->assertStatus(201);
}
/**
* Test that user registration returns the correct JSON structure.
*/
public function test_user_registration_returns_correct_json_structure(): void
{
$response = $this->registerUser();
$response->assertJsonStructure($this->registeredUserResponseStructure);
}
/**
* Test that the "me" endpoint returns a 200 status when accessed with a valid token.
*/
public function test_me_endpoint_returns_status_200_with_valid_token(): void
{
$response = $this->me();
$response->assertOk();
}
/**
* Test that the "me" endpoint returns the correct user data structure.
*/
public function test_me_endpoint_returns_correct_user_data_structure(): void
{
$this->me()->assertOk()->assertJsonStructure($this->userResponseStructure);
}
/**
* Test that the "me" endpoint returns a 403 status when accessed with an invalid token type.
*/
public function test_me_endpoint_returns_status_403_with_invalid_token_type(): void
{
$registered = $this->registerUser();
$refreshToken = $registered->json('new_tokens.1.token');
$this->me($refreshToken)->assertStatus(403)->assertJson([
'message' => 'This action is unauthorized.'
]);
}
/**
* Test that the "me" endpoint returns a 401 status when accessed with an invalid token.
*/
public function test_me_endpoint_returns_status_401_with_invalid_token(): void
{
$this->me('invalid token')->assertStatus(401)->assertJson([
'message' => 'Unauthenticated.'
]);
}
/**
* Test that the "me" endpoint returns a 403 status when the token cannot be used yet.
*/
public function test_me_endpoint_returns_status_403_when_token_cannot_be_used_yet(): void
{
Config::set('jwt.access.cbu', 30);
$me = $this->me();
$me->assertStatus(403)
->assertJson([
'message' => 'The token cannot be used yet'
]);
}
/**
* Test that the "me" endpoint returns a 403 status when the token is expired.
*/
public function test_me_endpoint_returns_status_403_when_token_is_expired(): void
{
Config::set('jwt.access.ttl', -10);
$me = $this->me();
$me->assertStatus(403)
->assertJson([
'message' => 'The token is expired'
]);
}
public function test_authenticate_endpoint_returns_status_200_when_provided_valid_credentials(): void
{
$auth = $this->authenticate([
'email' => $this->faker->safeEmail,
'password' => $this->faker->password,
]);
$auth->assertStatus(200)
->assertJsonStructure($this->authenticatedUserResponseStructure);
}
public function test_authenticate_endpoint_returns_401_when_provided_invalid_credentials(): void
{
$auth = $this->authenticate(authenticate: [
'email' => $this->faker->safeEmail,
'password' => $this->faker->password,
]);
$auth->assertStatus(401)
->assertJson([
'message' => 'Unauthenticated.'
]);
}
public function test_authenticate_endpoint_returns_422_when_provided_missing_credentials(): void
{
$auth = $this->authenticate(authenticate: [
'email' => $this->faker->safeEmail,
]);
$auth->assertStatus(422);
}
public function test_revoke_endpoint_returns_status_204(): void
{
$revoke = $this->revoke('new_tokens.0.token');
$revoke->assertStatus(204);
}
public function test_revoke_endpoint_returns_401_when_provided_invalid_token(): void
{
$revoke = $this->revoke('invalid token');
$revoke->assertStatus(401)
->assertJson([
'message' => 'Unauthenticated.'
]);
}
public function test_revoke_endpoint_returns_403_with_invalid_token_type(): void
{
$revoke = $this->revoke('new_tokens.1.token');
$revoke->assertStatus(403)
->assertJson([
'message' => 'This action is unauthorized.'
]);
}
public function test_revoke_endpoint_returns_correct_errors_when_provided_revoked_tokens_by_different_situations(): void
{
$registered = $this->registerUser();
$revokeRequest = fn (string $token) => $this->delete(route('auth.revoke'), [], [
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
]);
$revokedTokens = [
'access_token' => $registered->json('new_tokens.0.token'),
'refresh_token' => $registered->json('new_tokens.1.token'),
];
$access = current($revokedTokens);
$refresh = next($revokedTokens);
$revokeRequest($refresh)->assertStatus(403)->assertJson([
'message' => 'This action is unauthorized.'
]);
$revokeRequest($access)->assertNoContent()->isEmpty();
foreach($revokedTokens as $revokedToken) {
$revokeRequest($revokedToken)->assertStatus(403)->assertJson([
'message' => ($revokedToken === $refresh) ? 'This action is unauthorized.' : 'The token is revoked'
]);
}
}
public function test_revoke_endpoint_inserts_tokens_to_blacklist(): void
{
$registered = $this->registerUser();
$revoke = $this->delete(route('auth.revoke'), [], [
'Authorization' => 'Bearer ' . $registered->json('new_tokens.0.token'),
'Accept' => 'application/json',
]);
$revoke->assertNoContent();
collect($registered->json('new_tokens'))->each(function (array $token) {
$this->assertDatabaseHas((new TokenBlacklist())->getTable(), [
'jwt_token_id' => $token['claims']['jti'],
]);
});
}
public function test_refresh_endpoint_returns_201_with_valid_token_type(): void
{
$this->refresh('new_tokens.1.token')->assertCreated()->assertJsonStructure([
'new_tokens' => [
$this->tokenResponseStructure,
$this->tokenResponseStructure,
]
]);
}
public function test_refresh_endpoint_returns_403_with_invalid_token_type(): void
{
$this->refresh('new_tokens.0.token')->assertStatus(403)->assertJson([
'message' => 'This action is unauthorized.'
]);
}
public function test_refresh_endpoint_returns_401_when_provided_invalid_token(): void
{
$this->refresh('invalid token')->assertUnauthorized()->assertJson([
'message' => 'Unauthenticated.'
]);
}
public function test_refresh_endpoint_returns_valid_tokens_when_provided_valid_token(): void
{
$refreshRequest = fn (string $token) => $this->post(route('auth.refresh'), [], [
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
]);
$validJsonStructure = [
'new_tokens' => [
$this->tokenResponseStructure,
$this->tokenResponseStructure,
]
];
$registered = $this->registerUser();
$refreshToken = $registered->json('new_tokens.1.token');
$firstRefreshRequest = $refreshRequest($refreshToken);
$firstRefreshRequest->assertCreated()->assertJsonStructure($validJsonStructure);
$newRefreshToken = $firstRefreshRequest->json('new_tokens.1.token');
$secondRefreshRequest = $refreshRequest($newRefreshToken);
$secondRefreshRequest->assertCreated()->assertJsonStructure($validJsonStructure);
}
}
This test class covers the following scenarios:
- User Registration
- Validates successful registration (201 status)
- Ensures correct response structure
- “Me” Endpoint
- Checks successful retrieval of user data (200 status)
- Verifies correct data structure
- Tests various token scenarios:
- Invalid token type (403 status)
- Invalid token (401 status)
- Token not yet usable (403 status)
- Expired token (403 status)
- Authentication
- Tests with:
- Valid credentials (200 status)
- Invalid credentials (401 status)
- Missing credentials (422 status)
- Tests with:
- Token Revocation
- Verifies successful revocation (204 status)
- Checks behavior with:
- Invalid token (401 status)
- Invalid token type (403 status)
- Tests various revocation scenarios
- Token Refresh
- Ensures successful refresh (201 status)
- Validates behavior with:
- Invalid token type (403 status)
- Invalid token (401 status)
- Confirms return of valid tokens upon successful refresh
Each test method checks different situations, for example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...
public function test_me_endpoint_returns_status_200_with_valid_token(): void
{
$response = $this->me();
$response->assertOk();
}
public function test_me_endpoint_returns_status_403_when_token_is_expired(): void
{
Config::set('jwt.access.ttl', -10);
$me = $this->me();
$me->assertStatus(403)
->assertJson([
'message' => 'The token is expired'
]);
}
// ...
UserRepositoryTest
The UserRepositoryTest
class verifies that the create
method of `UserRepository works correctly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
namespace Tests\Feature\Repositories;
use App\Models\JWT\Token;
use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Collection;
use Tests\TestCase;
class UserRepositoryTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function test_create_method_creating_users_correctly(): void
{
$repository = app()->make(UserRepository::class);
$userData = [
'name' => $this->faker->name,
'email' => $this->faker->safeEmail,
'password' => $this->faker->password,
];
$user = $repository->create($userData);
$userID = $user->id;
$this->assertDatabaseHas((new User())->getTable(), [
'id' => $userID,
]);
/** @var Collection $tokens */
$tokens = $user->getAttribute('new_tokens');
$tokens->map(function (Token $record) use ($userID) {
$this->assertEquals($userID, $record->token->claims()->get('sub'));
});
}
}
This test ensures that a user is created correctly and appropriate tokens are assigned to them. It checks if the user data is properly stored in the database and if the generated tokens have the correct claims.
TokenValidationTest
The TokenValidationTest
class checks various aspects of the token validation process:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php
namespace Tests\Feature\Traits;
use Illuminate\Support\Facades\Config;
use Mockery;
use Tests\Assets\Traits\AuthTestHelpers;
use Tests\TestCase;
class TokenValidationTest extends TestCase
{
use AuthTestHelpers;
protected function tearDown(): void
{
parent::tearDown();
Mockery::close();
}
public function test_validate_method_revokes_the_token_when_provided_invalid_token(): void
{
Config::set('jwt.access.ttl', -10);
$registered = $this->registerUser();
$token = $registered->json('new_tokens.0');
$me = $this->me($token['token']);
$me->assertStatus(403)
->assertJson([
'message' => 'The token is expired'
]);
$this->assertDatabaseHas('jwt_token_blacklist', [
'jwt_token_id' => $token['claims']['jti'],
]);
}
public function test_validate_method_returns_correct_error_when_provided_revoked_token(): void
{
Config::set('jwt.access.ttl', -10);
$registered = $this->registerUser();
$token = $registered->json('new_tokens.0');
$this->me($token['token'])->assertStatus(403)->assertJson([
'message' => 'The token is expired'
]);
$this->assertDatabaseHas('jwt_token_blacklist', [
'jwt_token_id' => $token['claims']['jti'],
]);
$this->me($token['token'])->assertStatus(403)->assertJson([
'message' => 'The token is revoked'
]);
}
}
These tests verify that expired or revoked tokens are handled correctly. They simulate scenarios where tokens are expired or revoked and ensure that the system responds appropriately.
TokenServiceTest
The TokenServiceTest
class verifies that the build method of TokenService
works correctly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
namespace Tests\Unit\Services;
use App\Services\TokenService;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lcobucci\JWT\UnencryptedToken;
use Tests\TestCase;
class TokenServiceTest extends TestCase
{
public function test_build_method_can_builds_tokens(): void
{
$service = new TokenService();
$payloads = $service->data(Str::uuid()->toString());
$tokens = $payloads->map(function (Collection $payload) use ($service) {
return $service->build($payload);
});
$this->assertInstanceOf(Collection::class, $tokens);
$tokens->each(function ($token) use ($service) {
$this->assertInstanceOf(UnencryptedToken::class, $token);
});
}
}
This test confirms that TokenService
can create JWT tokens. It generates payloads, builds tokens and then asserts that the resulting tokens are of the correct type.
Running the Tests
To run all tests, enter the following command in the terminal:
1
php artisan test
To run a specific test class or method:
1
2
php artisan test --filter=AuthControllerTest
php artisan test --filter=AuthControllerTest::test_user_registration_returns_status_201
It’s a good practice to run these tests:
- After making any changes to the authentication system
- Before deploying to production
- As part of your continuous integration pipeline
This comprehensive test suite ensures that all aspects of our JWT-based authentication system are working as expected. By running these tests regularly, you can catch potential issues early in the development process and maintain the reliability of your authentication system.
Conclusion
In this testing section, we’ve built a comprehensive suite of tests to ensure the robustness and security of our JWT-based authentication system. These tests cover various scenarios such as user registration, authentication, token validation, and revocation. By leveraging PHPUnit and Laravel’s testing capabilities, we’ve simulated different cases to verify that our API behaves as expected under normal, edge, and invalid conditions.
Running these tests regularly helps ensure that new changes or features do not introduce bugs or break existing functionality. By maintaining a strong test suite, you’ll improve the reliability, security, and maintainability of your authentication system, ultimately delivering a better product to your users. Always keep your tests up to date as your application evolves.
Remember to update your tests whenever you add new features or modify existing functionality in your authentication system. This will help maintain in the integrity of your codebase and ensure that new changes don’t break existing functionality.
Final Thoughts
In this guide, we’ve walked through implementing secure JWT-based authentication in a Laravel 11 application using the lcobucci/jwt
package. By leveraging asymmetric encryption with RSA keys, we’ve ensured robust security for token issuance and validation. From generating keys and configuring middleware to handling token management with services and testing our implementation, this comprehensive approach provides a solid foundation for any Laravel project requiring authentication.
Remember, JWT-based authentication is powerful but requires careful implementation, especially around token security, expiration, and revocation. Regularly update your security practices and maintain a strong test suite to ensure that your authentication system remains reliable and secure.
Feel free to adapt and expand upon the examples presented here to fit your project’s needs, and always stay up to date with best practices for secure application development.