NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Declare the guards in config/auth.php — the package provides the drivers:

'guards' => [
    // Claims mode: build a TokenUser straight from the token (no DB).
    'api' => ['driver' => 'jwt'],

    // Provider mode: resolve a real Eloquent user via `sub`.
    // 'api' => ['driver' => 'jwt', 'provider' => 'users'],

    'service' => ['driver' => 'service-jwt'],
],

Then protect routes as usual:

Route::middleware('auth:api')->get('/me', fn () => ['id' => auth()->id()]);
Route::middleware('auth:service')->post('/internal/sync', SyncController::class);

In claims mode request()->user() is a TokenUser — not an Eloquent model, so return the fields you need (getAuthIdentifier(), claims()) rather than the object itself, which Laravel can’t turn into a response.

Claims mode and provider mode

  • Claims mode — no provider: the guard builds guard.identity (default TokenUser) straight from the verified claims. No database query.
  • Provider mode — with a provider: the guard calls retrieveById() with the sub claim, so $request->user() is your Eloquent model.
$user = $request->user();            // TokenUser in claims mode
$user->getAuthIdentifier();          // the 'sub' claim
$user->claims();                     // the full Claims bag
$user->hasPermission('posts.edit');  // membership in the 'permissions' claim

The request pipeline

On every request the jwt guard runs: bearer token → verify (RS256, pinned iss, the guard’s own audience) → required scope (default access) → denylist → identity → optional token-version check. Any failure yields a null user — a 401. A missing or invalid RSA key (KeyLoadFailed) is rethrown instead, so an operator error surfaces as a 500. The guard never dispatches TokenVerificationFailed; that event belongs to explicit Jwt::verify() calls.

Reading the current claims

Jwt::guard('api')->claims();       // ?Claims — that guard's verified claims, null without a user
Jwt::guard('clients')->claims();   // never another guard's claims

auth()->guard('api')->payload();   // the same claims, straight from the guard

Jwt::guard($name)->claims() names the guard it reads, so one guard’s claims never answer for another. Claims resolve through the auth manager’s per-request guards, never static state, so long-lived workers (Octane, queues) can’t leak one request’s claims into the next. Both guards honour setUser(): a user set on them is kept until forgetUser(), so Laravel’s actingAs($user, $guard) works (see Testing). validate() runs the same full pipeline:

// Runs the full pipeline: signature, pins, scope, denylist, identity, token version.
Auth::guard('api')->validate(['token' => $jwt]);   // bool

Token-version freshness

In provider mode, set guard.token_version so a bumped version invalidates old tokens. Prefer an invokable class — it survives php artisan config:cache, a closure does not:

namespace App\Auth;

use Illuminate\Contracts\Auth\Authenticatable;

final class TokenVersion
{
    public function __invoke(Authenticatable $user): int
    {
        return (int) $user->token_version;
    }
}
// config/jwt.php
'guard' => [
    'token_version' => \App\Auth\TokenVersion::class,   // __invoke(Authenticatable $user): int
],

The guard compares the returned int with the token’s tv claim; a mismatch or an absent tv rejects the token. Bump the stored version to log a user out everywhere — after a password change, for example:

// Mint with the user's current version…
Jwt::mintAccessToken(
    AccessTokenRequest::for($user->id)->tokenVersion($user->token_version)
);

// …then "log out everywhere": every token carrying the old tv is rejected.
$user->increment('token_version');

A value the guard cannot call — a missing class, a class without __invoke — throws JwtMisconfigured rather than silently skipping the check.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.