a2workspace/laravel-jwt
JWT authentication for Laravel: issue and validate JSON Web Tokens for stateless API auth. Plugs into Laravel's auth guards and middleware to protect routes, with configurable signing keys, token lifetimes, and claims for flexible, token-based access control.
composer require a2workspace/laravel-jwt — it pulls in tymon/jwt-auth:^2.2, so your PHP and laravel/framework versions must satisfy that dependency first. On Laravel 9+ this is usually fine; on older 8.x apps check before committing.php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider" then php artisan jwt:secret. The result is a JWT_SECRET in .env and a config/jwt.php you own from now on.config/auth.php: an api guard with 'driver' => 'jwt' and the users provider pointing at App\Models\User. The model must implement Tymon\JWTAuth\Contracts\JWTSubject (getJWTIdentifier() returning $this->getKey(), getJWTCustomClaims() usually returning []).Route::middleware('auth:api')->get('/me', ...).POST /api/login action returning ['access_token' => auth('api')->attempt($credentials), 'token_type' => 'bearer', 'expires_in' => auth('api')->factory()->getTTL() * 60], then GET /api/me with Authorization: Bearer <token>. If both work, the wiring is correct and you can stop reading setup docs.config/auth.php (guard driver, provider, defaults) and config/jwt.php (TTL, required_claims, blacklist). Almost every "valid token rejected" report lives in one of those two files.One small auth controller, four actions. login, me, refresh, logout, all going through the guard rather than the facade so the right provider resolves: $this->guard()->attempt(...), ->user(), ->refresh(), ->logout(). logout() only actually invalidates anything when jwt.blacklist_enabled is true — if you want real revocation, turn it on.
Mint from a known user when you already have one. After registration, an OAuth callback, or a password reset, you hold a User instance — use JWTAuth::fromUser($user) instead of round-tripping credentials through attempt(). Same call is the cleanest way to make tokens in seeders, factories, and tests.
Refresh is a rotation, not a renewal. With blacklist_enabled = true, refresh() returns a new token and blacklists the old one. Clients must overwrite their stored token on every refresh; anything that treats refresh as advisory will replay the old token and start getting TokenBlacklistedException. Set jwt.refresh_ttl meaningfully higher than jwt.ttl or the refresh window is a fiction.
Handle expiry separately. Catch TokenExpiredException apart from TokenInvalidException/JWTException so clients get a distinct "refresh now" signal instead of a generic 401. In Laravel 9+ register this as a renderable in bootstrap/app.php (or the exception Handler) rather than wrapping every controller action in try/catch.
Don't hit the login endpoint in feature tests. Build the user, get a token via JWTAuth::fromUser($user), and attach it with $this->withHeader('Authorization', "Bearer {$token}")->getJson(...). For expiry behaviour use Carbon::setTestNow() or JWTAuth::setTTL(0) — never sleep().
Reset the guard in queue workers and Octane. The resolved guard survives between jobs, so token/user state leaks. Call auth('api')->setUser($user) or JWTAuth::setToken(...) explicitly at the top of each job, or forget the guard.
Per-guard lifespans. Once ttl/refresh_ttl exist in the guard's config/auth.php entry, the guard reads them from there rather than config/jwt.php. Use this for separate mobile (30 days) and admin (2 hours) token lifetimes instead of mutating global config at runtime.
tymon/jwt-auth 2.x; when you hit real behaviour questions, read upstream docs and source, not this repo. With 4 stars and a single-line 2.2.0 release ("bump the dependency"), treat it as a convenience layer with a low bus factor — if you only need the dependency plus config, depending on tymon/jwt-auth directly removes a maintenance risk with zero functional loss.file driver makes invalidation unreliable across multiple app servers. Use Redis or the database store in production.iat grace period bites immediate callers. Refresh or invalidate right after issuance and you can get a blacklist error because iat equals "now". jwt.blacklist_grace_period exists for exactly this; the default of 0 is too tight for some clients..env edits. php artisan config:cache bakes JWT_SECRET and TTLs into compiled config — a "missing secret" after deploy is nearly always stale cached config. php artisan config:clear after any env change.web guard. Auth::attempt() hits the session guard and will not issue a JWT, and auth:api returning 401 with a visibly correct token usually means defaults.guard or a provider mismatch in config/auth.php.me.jwt.leeway when the issuer and verifier are different hosts; nbf/iat drift is the usual cause of "works locally, fails in staging".Authorization header before request logging reaches production. Default middleware in several packages will happily write tokens to disk.config/jwt.php. Publish --force to a scratch directory and diff — the 2.x line reworked keys (providers.*.storage, providers.jwt.lock_subject, required_claims), and a stale config can boot fine and then fail at validation time.jwt.providers.storage, override the claim factory for custom iss/aud, and use JWTSubject::getJWTCustomClaims() for anything you need before a DB hit. Resist wrapping the guard in your own abstraction — it generally breaks refresh() semantics for very little gain.How can I help you explore Laravel packages today?