php-open-source-saver/jwt-auth
Maintained fork of tymondesigns/jwt-auth providing JWT authentication for Laravel. Same API with the PHPOpenSourceSaver\JWTAuth namespace, making migration easy, plus ongoing support and full documentation.
1. Install and scaffold (5 minutes)
composer require php-open-source-saver/jwt-auth
php artisan vendor:publish --provider="PHPOpenSourceSaver\JWTAuth\Providers\LaravelServiceProvider"
php artisan jwt:secret # appends JWT_SECRET=... to .env
jwt:secret writes to .env, but in containerised setups set JWT_SECRET as a real environment variable instead and skip the command — the package throws an explicit exception at boot if no secret / key material is present (added in 1.4.2), which is a far better failure mode than silently signing with an empty key.
2. Wire up the guard
config/auth.php:
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
'api' => ['driver' => 'jwt', 'provider' => 'users'], // add this
],
3. Implement the contract on your User model
use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
public function getJWTIdentifier() { return $this->getKey(); }
public function getJWTCustomClaims(): array { return []; }
}
4. First use case — token issuing + protected route
// routes/api.php
Route::post('login', [AuthController::class, 'login']);
Route::post('refresh', [AuthController::class, 'refresh']); // deliberately outside auth:api
Route::middleware('auth:api')->group(function () {
Route::get('me', [AuthController::class, 'me']);
Route::post('logout', [AuthController::class, 'logout']);
});
public function login(LoginRequest $request)
{
$token = auth('api')->attempt($request->only('email', 'password'));
if (! $token) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
return $this->respondWithToken($token);
}
protected function respondWithToken(string $token)
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
public function me() { return response()->json(auth('api')->user()); }
public function logout() { auth('api')->logout(); return response()->json(['message' => 'Logged out']); }
public function refresh(){ return $this->respondWithToken(auth('api')->refresh()); }
Where to look first: config/jwt.php (this is the whole system's behaviour in one file — ttl, refresh_ttl, blacklist_enabled, blacklist_grace_period, algo, keys, cookie name), then the published docs at laravel-jwt-auth.readthedocs.io. The README is thin by design; the readthedocs site is the actual manual.
Migration in one line: the API is identical to tymondesigns/jwt-auth — swap the namespace (Tymon\JWTAuth → PHPOpenSourceSaver\JWTAuth), remove the old package, require this one. The only hard break is JWTGuard's new required $eventDispatcher constructor argument, which only bites you if you built the guard by hand or disabled package discovery.
The four-endpoint auth controller is the canonical shape. attempt() returns a token string (not a boolean), login($user) returns a token for an existing model, logout() invalidates the current token, refresh() invalidates-and-reissues. Anything else you build sits on top of these.
Two different token lifetimes to keep straight. jwt.ttl (default 60 min) is the token's own exp. jwt.refresh_ttl (default 2 weeks) is the window in which a refresh is still allowed. Past refresh_ttl, the user must log in again regardless of activity — so a "sliding session" UX needs refresh_ttl sized to your idle timeout, not ttl.
Choose your delivery channel deliberately.
| Client | Pattern |
|---|---|
| Mobile / service-to-service | Authorization: Bearer <token> in the response body, client stores it |
| SPA / browser | Token in an httpOnly cookie (rename it via cookie_key_name, added 2.7.2) so JS can't exfiltrate it; CSRF handled separately |
The guard reads the token from the Authorization header by default and falls back to the cookie; it also correctly ignores other auth schemes (e.g. Basic) in the header — that bug was fixed in 2.0.
Never put the refresh endpoint behind auth:api. An expired token fails the auth:api middleware before your controller runs, so a client at ttl boundary can never reach refresh(). Keep /refresh outside the group — auth('api')->refresh() only needs a parsable token, not a live one. If you prefer middleware, the package registers the jwt.refresh alias, which parses, refreshes, and attaches the new token to the response.
Custom claims are your authorisation shortcut, but keep them small. Override getJWTCustomClaims() for things the client needs before any API call (tenant id, role, feature flags) and things you want to check without a DB hit:
public function getJWTCustomClaims(): array
{
return ['ten' => $this->tenant_id, 'rol' => $this->role];
}
For per-request overrides use JWTAuth::customClaims(['scope' => 'read'])->fromUser($user). Reserved claim names (sub, iat, exp, nbf, iss, jti, aud, prv) get clobbered — don't use them.
Use claims to skip queries, not to replace authorisation. A tenant claim is fine for scoping a query; a rol => admin claim is fine only if ttl is short. Anything that must be revocable now belongs in the DB — JWTs are revoked only via the blacklist, which is per-token, not per-user.
Per-guard TTLs (2.2+). If you run admin and customer guards side by side, you can give each its own ttl on the guard definition rather than fighting global config — short tokens for admins, long ones for kiosk apps.
Reaching for the underlying service. auth('api') covers login/logout/refresh/user. For everything else inject or resolve PHPOpenSourceSaver\JWTAuth\JWTAuth:
JWTAuth::fromUser($user); // mint a token for a model
JWTAuth::parseToken()->getPayload(); // inspect claims
JWTAuth::setToken($t)->invalidate(); // revoke an arbitrary token (e.g. from a "sessions" screen)
JWTAuth::setTTL(5)->fromUser($user); // one-off short-lived token (email link, download)
JWTAuth::parseToken()->getPayload()->get('sub');
Exception → client contract. The single most valuable thing you can build is a distinct 401 vocabulary so the client knows whether to refresh or re-authenticate:
// Laravel 11/12/13 bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(fn (TokenExpiredException $e, $r) =>
$r->expectsJson() ? response()->json(['code' => 'token_expired'], 401) : null);
$exceptions->render(fn (TokenBlacklistedException $e, $r) =>
$r->expectsJson() ? response()->json(['code' => 'token_revoked'], 401) : null);
$exceptions->render(fn (TokenInvalidException $e, $r) =>
$r->expectsJson() ? response()->json(['code' => 'token_invalid'], 401) : null);
$exceptions->render(fn (UserNotDefinedException $e, $r) =>
$r->expectsJson() ? response()->json(['code' => 'user_gone'], 401) : null);
})
Because the guard resolves lazily (check() during middleware vs authenticate() in the controller), some paths surface AuthenticationException → generic 401 instead. Handle both: generic 401 means "give up and log in", token_expired means "call /refresh".
Resource-ownership checks still belong in policies. auth('api')->user() is an Authenticatable, so $this->authorize('update', $post) and Gate work exactly as with session auth — don't hand-roll if ($post->user_id !== auth()->id()).
Logout with a session list. Since logout() only invalidates the presented token, a "log out everywhere" feature needs storage of issued tokens (store the jti claim keyed by user in Redis) and iterating them through JWTAuth::setToken($t)->invalidate().
Testing. $this->actingAs($user, 'api') works for route-level tests, but token-specific behaviour needs a real token:
$token = auth('api')->login($user);
$this->withHeader('Authorization', "Bearer {$token}")->getJson('/api/me')->assertOk();
// expiry, without waiting
$this->travel(61)->minutes();
$this->withHeader('Authorization', "Bearer {$token}")->getJson('/api/me')->assertStatus(401);
The blacklist is stateful — and that changes your infra. blacklist_enabled defaults to true, so logout, invalidate, and refresh all write to the cache store. Consequences: multiple app servers must share a cache (Redis/Memcached, never file or array); php artisan cache:clear silently un-revokes every "logged out" token until it expires — so keep ttl short rather than relying on revocation alone. If you set blacklist_enabled => false, logout() becomes a no-op client-side concern and refresh stops invalidating the old token: acceptable only with very short TTLs.
refresh() is not idempotent. It invalidates the presented token, so two parallel requests from the same client (two tabs, a duplicate axios interceptor) will race and one gets TokenBlacklistedException. Fix with a client-side refresh lock, or set blacklist_grace_period so the just-refreshed token stays usable for a few seconds. This is the #1 cause of random logouts in SPAs.
iat now moves on refresh — know which version you're on. 2.5 made refresh() update iat; 2.8 added config to keep it unchanged; 2.8.3 fixed the config not actually applying. If you derive "session started at" or absolute expiry from iat, pin ^2.8.3 or later and verify the behaviour explicitly.
Auth::id() semantics changed. Since 2.6/2.7 id() delegates to getUserId(), which reads sub and falls back to id (2.3+). If you issue tokens for non-id identifiers, getUserId() is the accessor to use — don't assume sub exists.
Blacklist exceptions used to be silent. show_black_list_exception flipped to true in 2.0. If you're carrying config forward from a 1.x install, your TokenBlacklistedException handling may not exist yet, and callers will suddenly see 500s instead of 401s.
Octane: pin >=2.9.3. Pre-2.9.3 the tymon.jwt singleton leaked across requests under Octane, producing cross-request token bleed. 2.9.3 scopes the binding. If you're on Octane, this is a must-patch, plus php artisan octane:reload after changing config/jwt.php.
Cached config will bite you. php artisan config:cache means JWT_* env edits do nothing until you re-cache. jwt:secret writes .env, not config — so in a cached-config deploy you must regenerate the cache.
Cookie mode requires the cookie to survive middleware. The token cookie must be excluded from EncryptCookies only if you set it raw; if you let the package set it, Laravel's encryption applies and an invalid encrypted cookie now throws (1.1.0) rather than being ignored. Also add the cookie name to VerifyCsrfToken's exceptions for login/refresh if you rely on it, or you'll get 419s before the guard ever runs.
Asymmetric keys: use the command, not openssl-by-hand. php artisan jwt:generate-certs (with --ask-passphrase) creates the RSA/ECDSA pair the config expects. Set JWT_ALGO to RS256/ES256 and provide JWT_PUBLIC_KEY / JWT_PRIVATE_KEY / JWT_PASSPHRASE. This is what lets a second service (another Laravel app, a Go gateway) verify tokens with only the public key — the main reason to pick RS256 over HS256. Note ECDSA had a real bug fixed in 1.1.1, so don't pin anything older.
Clock skew between issuing and verifying hosts shows up as sporadic TokenExpiredException right after login. Either sync NTP or give nbf/exp a small leeway; don't "fix" it by inflating ttl.
Don't put the JWT guard on web. If web uses driver => jwt, Auth::attempt() returns a token string where the framework expects a boolean, sessions stop working, and every Blade @auth becomes a token check. Keep web on session and api on jwt — or use auth()->guard('api') / auth('api') consistently rather than the default guard.
Extension points worth knowing: bind your own PHPOpenSourceSaver\JWTAuth\Contracts\Providers\Auth implementation to change how credentials are validated; override the Blacklist binding to store revocations in something other than cache; and register listeners for the auth events dispatched since 1.2.0 (JWTAttempting, JWTLogin, JWTAuthenticated, JWTValidated, JWTLoginFailed, JWTFailed) to audit or rate-limit at the guard layer instead of the controller.
Version policy: 2.8 dropped PHP 8.1; 2.9 tracked Laravel 13. Pin ^2.9 and read changelog/ for iat and Octane changes before upgrading — the package is actively maintained (unlike the original Tymon package it forks), but the release notes are the only place the behavioural tweaks are documented.
How can I help you explore Laravel packages today?