irazasyed/jwt-auth-guard
A JWT authentication guard for Laravel and Lumen that issues, verifies, and refreshes JSON Web Tokens as a drop-in replacement for session-based guards, with support for custom claims and multiple auth drivers.
Know what you're installing first. irazasyed/jwt-auth-guard is a thin auth-guard adapter on top of tymon/jwt-auth (0.5.*). It was last released in April 2016 targeting Laravel 5.1/5.2 and PHP 5.5.9+. Pin laravel/framework and tymon/jwt-auth explicitly in composer.json before running composer require irazasyed/jwt-auth-guard, because Composer will otherwise pull an incompatible tymon release and the guard will break at boot.
Minimal setup, in order:
composer require irazasyed/jwt-auth-guard (this pulls tymon in as a dependency).Irazasyed\JwtAuthGuard\JwtAuthGuardServiceProvider in config/app.php — there is no package auto-discovery on these Laravel versions.php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\JWTAuthServiceProvider" and generate a secret with php artisan jwt:secret (writes JWT_SECRET into .env). An empty secret produces TokenInvalidException on every request — this is the #1 first-run failure.config/auth.php:'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
User model implement the tymon contract so tokens can be minted:use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
public function getJWTIdentifier() { return $this->getKey(); }
public function getJWTCustomClaims() { return []; }
}
First use case — a login endpoint that hands back a token. The guard's attempt() returns the JWT string on success and false on failure:
Route::post('api/login', function () {
$credentials = request()->only('email', 'password');
if (! $token = Auth::guard('api')->attempt($credentials)) {
return response()->json(['error' => 'invalid_credentials'], 401);
}
return response()->json(compact('token'));
});
Then protect a route with auth:api and send Authorization: Bearer <token>. Auth::guard('api')->user() resolves the user from the token's sub claim via the users provider — no session, no cookies.
Set the API guard as default for an API-only app. If every request is token-authenticated, set 'defaults' => ['guard' => 'api'] in config/auth.php. Then auth()->user(), auth()->attempt() and the plain auth middleware all hit the JWT guard, and you avoid sprinkling guard('api') everywhere. In mixed apps, keep web as default and be explicit — Auth::attempt() routes to the default guard, not the API one.
Protect route groups, not individual routes:
Route::group(['prefix' => 'api', 'middleware' => ['api', 'auth:api']], function () {
Route::get('me', fn () => response()->json(auth()->user()));
Route::post('logout', function () {
auth()->logout(); // blacklists the current token
return response()->json(['ok' => true]);
});
});
Refresh flow. refresh() invalidates the presented token (blacklisted) and returns a new one; store it client-side and discard the old one. The window is refresh_ttl (14 days by default) while ttl is 60 minutes — a client should hit /refresh proactively on a 401 with token_expired, not reactively on every failure.
Route::post('api/refresh', function () {
try {
return response()->json(['token' => Auth::guard('api')->refresh()]);
} catch (Tymon\JWTAuth\Exceptions\JWTException $e) {
return response()->json(['error' => 'token_blacklisted'], 401);
}
});
Custom claims. Don't try to pass claims through the guard's attempt() — go to the underlying service, which is reachable either via the JWTAuth facade or by __call proxying on the guard:
$token = JWTAuth::customClaims(['role' => $user->role, 'tenant' => $user->tenant_id])
->fromUser($user);
Read them back with JWTAuth::getPayload($token)->get('role'). Keep custom claims small — every claim is re-sent on every request.
Middleware / exception integration. Because the guard plugs into Laravel's native auth, auth:api works as-is, but the stock Authenticate middleware only returns JSON when the request "expects JSON." Make your API clients send Accept: application/json, or override the middleware for API routes and always return the 401 envelope.
Testing. Mint a real token in the test rather than faking the guard:
$token = JWTAuth::fromUser($user);
$this->call('GET', '/api/me', [], [], [], [
'HTTP_AUTHORIZATION' => 'Bearer '.$token,
'HTTP_ACCEPT' => 'application/json',
]);
This exercises the actual guard parsing, which is where most bugs live.
Illuminate\Contracts\Auth\Guard no longer declares attempt(), and tymon 1.x renamed namespaces (Tymon\JWTAuth\Facades\JWTAuth, JWTAuth::parseToken() → JWTAuth::getToken()). You cannot upgrade tymon independently — the guard's internals call the 0.5 API. Treat the versions as a fixed unit or migrate to tymon 2.x / Sanctum / Passport.user() lets TokenExpiredException / TokenInvalidException propagate; Illuminate\Auth\AuthenticationException is the only thing Authenticate catches. Register handlers for Tymon\JWTAuth\Exceptions\JWTException (and the TokenExpiredException subclass) in app/Exceptions/Handler.php and return 401 with a machine-readable code, so clients can distinguish token_expired from token_invalid and decide whether to refresh.Authorization header may disappear. Apache with mod_php/CGI and some PHP-FPM setups strip it. Add SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 (Apache) or fastcgi_param HTTP_AUTHORIZATION $http_authorization (nginx) before you spend an afternoon debugging a guard that "works in Postman."attempt(), the guard caches $this->user. If you call auth()->user() immediately in the same request and get null, either call auth()->setUser($user) yourself or re-resolve through the guard. Don't assume attempt() populates the user instance.blacklist_enabled plus a persistent cache store. With the array driver (the default in phpunit.xml) each test request boots a fresh cache, so a "logged out" token still authenticates. Use database/redis/file in tests that assert invalidation.blacklist_grace_period (default 0) keeps a token valid for N seconds after logout to tolerate concurrent in-flight requests. Set it explicitly and document it — it confuses QA when a "revoked" token still works.customClaims() merges into the payload; setting sub, iat, exp, nbf or jti corrupts validation or blacklisting. Use namespaced names like app_role.customClaims() is sticky for the request. The setter mutates the JWTAuth instance; if you mint two tokens in one request (e.g., in a test loop or a queue job), reset with JWTAuth::customClaims([]) or re-resolve the service.ttl and refresh_ttl are in minutes, not seconds. jwt.user and jwt.identifier in config/jwt.php are only used by JWTAuth::attempt()/fromUser() paths — when you go through the guard, the provider in config/auth.php wins, and the two can drift silently. Keep them pointed at the same model.__call. That's convenient (Auth::guard('api')->getPayload()) but means typos fail at runtime, not at static analysis. If your IDE can't see a method, that's why — reach for the JWTAuth facade in anything non-trivial for clarity.Auth::extend('jwt', ...) closure that decorates the package's guard and calls customClaims() inside attempt(). That keeps you upgradeable (within 0.5.x) and testable.How can I help you explore Laravel packages today?