Laravel API Authentication Using Firebase PHP JWT: Building a Login System and Custom Guard
Introduction
In a previous article, we laid the groundwork for implementing JWT authentication in Laravel using the Firebase PHP JWT package. We created a robust JWTCodec class that handles token encoding and decoding, initially using symmetric cryptography with a shared secret key. We then enhanced security by transitioning to asymmetric (public-key) cryptography using RSA key pairs.
In this article, we will build on this foundation by implementing user authentication and token issuance. We'll create a custom guard to authenticate API requests using JWTs, then build a login endpoint that returns JWTs and another endpoint that returns the authenticated user, using the guard to prevent unauthorized access.
Setup
We need to create a guard to authenticate api requests to our application. Open config/auth.php
and make these changes:
'guards' => [
// other predefined guards
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
The guards array defines different authentication guards in the application, with each guard specifying how users are authenticated for each request. We created a new api
guard, and configured it to use a custom jwt authentication driver and the users provider for handling authentication.
Next we need to create the jwt
driver that will be used for our newly created api
guard. All laravel authentication drivers must implement the Illuminate\Contracts\Auth\Guard
contract which defines the following methods:
<?php
namespace Illuminate\Contracts\Auth;
interface Guard
{
public function check();
public function guest();
public function user();
public function id();
public function validate(array $credentials = []);
public function hasUser();
public function setUser(Authenticatable $user);
}
Note that there is another guard contract, Illuminate\Contracts\Auth\StatefulGuard
, which extends the base Guard
contract and is designed for authentication systems that maintain application state. However, since API authentication is stateless, the focus will be on the base Guard
contract. Most methods defined by the Guard
contract are already implemented in the Illuminate\Auth\GuardHelpers
trait. If we leverage this trait, we would need to implement only two of the methods defined by the contract in our custom guard.
<?php
declare(strict_types=1);
namespace App\Guards;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Timebox;
class JwtGuard implements Guard
{
use GuardHelpers;
protected Request $request;
protected JWTCodec $codec;
protected int $minimumExecutionTime = 200000;
protected ?Timebox $timebox;
protected ?Authenticatable $lastAttempted = null;
public function __construct(UserProvider $provider, Request $request, JWTCodec $codec, ?Timebox $timebox = null)
{
$this->provider = $provider;
$this->request = $request;
$this->codec = $codec;
$this->timebox = $timebox ?: new Timebox();
}
/**
* {@inheritDoc}
*
* @throws \Throwable
*/
public function user(): ?Authenticatable
{
return $this->user ??= $this->getUserFromToken($this->request->bearerToken());
}
/**
* {@inheritDoc}
*/
public function validate(array $credentials = []): bool
{
if (empty($credentials)) {
return false;
}
try {
$user = $this->provider->retrieveByCredentials($credentials);
$hasValidCredentials = $this->hasValidCredentials($user, $credentials);
if ($hasValidCredentials) {
$this->lastAttempted = $user;
}
return $hasValidCredentials;
} catch (\Throwable $exception) {
Log::error('Jwt guard credentials validation failed', [
'message' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
return false;
}
}
public function getLastAttempted(): ?Authenticatable
{
return $this->lastAttempted;
}
/**
* @throws \Throwable
*/
protected function getUserFromToken(?string $bearerToken): ?Authenticatable
{
if (empty($bearerToken)) {
return null;
}
$decodedToken = $this->codec->decode($bearerToken);
if (empty($decodedToken) || empty($decodedToken['sub'])) {
return null;
}
return $this->provider->retrieveById($decodedToken['sub']);
}
/**
* @throws \Throwable
*/
protected function hasValidCredentials(?Authenticatable $user, array $credentials): bool
{
return $this->timebox->call(function (Timebox $timebox) use ($user, $credentials) {
$validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
if ($validated) {
$timebox->returnEarly();
}
return $validated;
}, $this->minimumExecutionTime);
}
}
The user
method retrieves the authenticated user for the current request. If the user has not already been resolved during the request lifecycle, the method extracts the JWT from the Authorization header, decodes it, and fetches the user from the database using the token's subject
claim.
The validate
method is used for login purposes to validate user credentials. It identifies a user based on the provided credentials and verifies them. If the credentials pass the validation attempt, the user whose credentials were validated is set as the lastAttempted
property of the class. The getLastAttempted
method provides access to the protected lastAttempted
class property.
The hasValidCredentials
method performs a secure validation of a user against given credentials. The method uses a Timebox
to prevent timing attacks that could leak information about credential validation. You can learn more about the Timebox
and its use from this article and this resource.
Finally, register the custom guard in the application's container:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\JWTCodec;
use App\Guards\JwtGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Foundation\Application;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Auth::extend('jwt', function (Application $app, string $name, array $config) {
return new JwtGuard(
Auth::createUserProvider($config['provider']),
$this->app['request'],
$app->make(JWTCodec::class)
);
});
}
}
Note that the driver name used as the first argument of the extend method must match the name of the driver that was added in the config.auth.php
file.
Build the login endpoint
The laravel 11 scaffold doesn't come with an api routes file; so let's create one:
touch routes/api.php
Next, we are going to register the api routes file in the application's bootstrap process. This step is necessary so as to automatically apply the api middleware group to the api routes and group all the api endpoints with the /api
prefix. Open bootstrap/app.php
and make the following changes:
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // add this line
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {})
->withExceptions(function (Exceptions $exceptions) {})
->create();
Now that we have registered our api routes file, create the api login endpoint that will respond to HTTP requests:
<?php
declare(strict_types=1);
use App\Http\Controllers\LoginController;
use App\Http\Resources\UserResource;
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
Route::name('api.')->group(function () {
Route::middleware('guest')->post('/login', LoginController::class)->name('login');
});
Before creating the LoginController
, let's take a small detour to customise the default guest
middleware to suit our api needs.
In Laravel 11, middleware aliases are defined in Illuminate\Foundation\Configuration\Middleware
. The guest
alias in particular, points to Illuminate\Auth\Middleware\RedirectIfAuthenticated
. By default, this middleware is designed to redirect authenticated users to a specific route if they attempt to access routes intended for unauthenticated users. However, this behavior is unsuitable for API endpoints, as APIs are stateless and typically do not rely on redirection. Instead, we want to return appropriate HTTP responses, such as a 403 Forbidden
or 401 Unauthorized status
, when authenticated users access restricted routes. To achieve this, we need to customize this middleware to align it with API behavior.
Let's start by creating a new middleware:
php artian make:middleware RedirectIfAuthenticated
Open the middleware created and paste the code below:
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\Middleware\RedirectIfAuthenticated as BaseRedirectIfAuthenticated;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated extends BaseRedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(Request): (Response) $next
*
* @throws \Throwable
*/
public function handle(Request $request, \Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
throw_if(
$request->expectsJson(),
new AuthorizationException('Authenticated users cannot perform this action.')
);
return redirect($this->redirectTo($request));
}
}
return $next($request);
}
}
The middleware above differs from the default RedirectIfAuthenticated
middleware in that it handles API and non-API requests differently. For API requests (determined by the expectsJson
method on the request object), it throws an exception instead of redirecting. For non-API requests, it retains the default behavior of redirecting the user to a predefined route.
Next, override the framework's default guest
middleware alias with our custom implementation. Open bootstrap/app.php
and make the following changes:
// other use statements
use App\Http\Middleware\RedirectIfAuthenticated;
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) {
// add this line
$middleware->alias(['guest' => RedirectIfAuthenticated::class,]);
})
->withExceptions(function (Exceptions $exceptions) {})
->create();
Now that we are done cutomising the guest
middleware, let's proceed to creating the LoginController
.
php artisan make:controller LoginController --invokable
Paste the code below into the controller:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\JWTCodec;
use App\Guards\JwtGuard;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class LoginController extends Controller
{
/**
* @throws \Throwable
*/
public function __invoke(Request $request, JWTCodec $codec): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
/** @var JwtGuard $guard */
$guard = auth()->guard('api');
throw_if(
! $guard->validate($credentials),
new AuthenticationException(trans('auth.failed'))
);
$user = $guard->getLastAttempted();
$payload = [
'jti' => Str::uuid()->getHex()->toString(),
'exp' => Carbon::now()->addHour()->getTimestamp(),
'iat' => Carbon::now()->getTimestamp(),
'nbf' => Carbon::now()->getTimestamp(),
'sub' => $user->getAuthIdentifier(),
];
$token = $codec->encode($payload);
throw_if(
$token === null,
new AuthenticationException(trans('auth.failed'))
);
event(new Authenticated('jwt', $user));
return response()->json(['data' => [
'user' => new UserResource($user->withoutRelations()),
'token' => [
'type' => 'Bearer',
'access_token' => $token,
'expires_at' => (string) $payload['exp'],
],
]]);
}
}
When invoked, the controller first validates the incoming request to ensure an email and password are provided. It then uses the JwtGuard
to validate these credentials against stored user information.
On successful validation, the user whose authentication was last attempted is retrieved from the guard and a JWT payload with standard claims is constructed. Using the JWTCodec
, the payload is encoded into a JWT, with an exception being thrown if token generation fails.
After generating the token, the controller fires an authentication event and returns a JSON response containing the authenticated user object transformed using a resource class, along with the JWT information, including the token type, the access token itself, and its expiration time.
We are almost done with our login endpoint but we don't have a UserResource
yet. So let's create one:
php artisan make:resource UserResource
Paste the code below in the created resource class:
<?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 [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'email_verified_at' => isset($this->email_verified_at) ? (string) $this->email_verified_at->timestamp : null,
'created_at' => isset($this->created_at) ? (string) $this->created_at->timestamp : null,
'updated_at' => isset($this->updated_at) ? (string) $this->updated_at->timestamp : null,
];
}
}
The UserResource
is a Laravel API resource that transforms a User
model into a standardized JSON response. The class exposes specific user attributes and converts timestamp fields to string representations, providing a clean, consistent data structure for API responses while preventing exposure of sensitive user information.
To test that the api
guard works, we'll add a route in the api.php
routes file that uses the api
middleware. Open the file and add a new endpoint:
// other use statements
use Illuminate\Http\Request;
Route::name('api.')->group(function () {
// other route declarations
Route::middleware('auth:api')->get('/user', function (Request $request) {
$user = $request->user();
return response()->json(['data' => new UserResource($user->withoutRelations())]);
})->name('user');
});
This route allows only users with valid JWTs to access it. On successful authentication, we retrieve the authenticated user's information and return it as a JSON response.
Test the api endpoints we just built using tools like Postman to send requests and verify responses. Note that you must set the Accept
header of your requests to application/json
.
Some minor refactoring
What we have works great already but manually building JWT payloads in the controller introduces repetitive, error-prone logic that could lead to inconsistent token generation with missing required claims like subject or expiry. We can create a builder class to handle JWT payload creation more robustly:
<?php
declare(strict_types=1);
namespace App\Builders;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class JwtPayloadBuilder
{
private array $payload = [];
public function __construct(Authenticatable $user)
{
$this->forSubject($user)
->generateIdentifier()
->issuedBy(config('app.url'))
->lifetimeInHours(config('jwt.token_lifetime'));
}
public function withClaim(string $claim, bool|float|int|string $value): self
{
$this->payload[$claim] = $value;
return $this;
}
public function withClaims(array $claims): self
{
foreach ($claims as $key => $value) {
$this->withClaim($key, $value);
}
return $this;
}
public function lifetimeInHours(int $hours): self
{
return $this->withClaim('exp', Carbon::now()->addHours(abs($hours))->getTimestamp());
}
public function issuedNow(): self
{
$now = Carbon::now();
return $this->withClaim('iat', $now->getTimestamp())->withClaim('nbf', $now->getTimestamp());
}
public function getPayload(): array
{
return $this->payload;
}
private function forSubject(Authenticatable $user): self
{
return $this->withClaim('sub', $user->getAuthIdentifier());
}
private function generateIdentifier(): self
{
return $this->withClaim('jti', Str::uuid()->getHex()->toString());
}
private function issuedBy(string $issuer): self
{
return $this->withClaim('iss', $issuer);
}
}
The JwtPayloadBuilder
is a fluent interface for constructing JSON Web Token (JWT) payloads. It automatically generates standard claims like a unique token identifier (jti), subject (sub), issuer (iss), and expiration (exp) while allowing custom claims to be added. The builder requires any class that implements the Illuminate\Contracts\Auth\Authenticatable
contract and provides methods to configure token-specific details like lifetime and issuance time.
Next, add the token_lifetime
entry to config/jwt.php
as follows:
/*
|--------------------------------------------------------------------------
| Token Lifetime Expiry
|--------------------------------------------------------------------------
|
| Here you may define the amount in hours before authentication tokens expire
|
*/
'token_lifetime' => env('JWT_TOKEN_LIFETIME', 1),
Now, we can replace our payload generation logic in LoginController
with the builder:
$payload = (new JwtPayloadBuilder($user))
->issuedNow()
->getPayload();
Summary and Next Steps
In this part of the series, we focused on creating a secure authentication system for API endpoints. This involved configuring and implementing a custom JWT guard to handle token-based authentication, and building a login endpoint with robust credential validation. The implementation of the JWT guard incorporates protection against timing attacks, and also adheres to Laravel's authentication guard contract.
In the next part of this series, we will explore two-factor authentication (2FA) and how to use it to provide users with enhanced protection beyond traditional username and password credentials, thereby offering a more robust defense against unauthorized access.
The code for this tutorial is available on GitHub for you to read, clone, fork or star. If you have any challenges with the tutorial, I can be reached via Twitter/X or LinkedIn. Please don't forget to share if you have found it useful.