waventra / laravel-jwt-token
Secure JWT authentication for Laravel with rotating refresh tokens, reuse detection, and revocation.
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- illuminate/auth: ^10.0|^11.0|^12.0
- illuminate/cache: ^10.0|^11.0|^12.0
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/events: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.21|^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
README
Stable version 1.0.0.
Secure JWT authentication for Laravel with short-lived access tokens and rotating refresh tokens.
Access tokens are signed JWTs using a built-in codec (OpenSSL + HMAC). There is no firebase/php-jwt (or other JWT) dependency. Refresh tokens are opaque secrets stored only as HMAC hashes. Each refresh rotates the token; reusing an old one revokes the entire session family.
Contents
- Requirements
- Installation
- Quick start
- Configuration
- Usage
- Client integration
- API reference
- Artisan commands
- Events
- Exceptions
- Security model
- Maintenance
- Troubleshooting
Requirements
- PHP 8.2+ with
ext-jsonandext-openssl - Laravel 10, 11, or 12
- A cache store (array is fine locally; Redis is recommended in production for refresh-token grace retries across servers)
Installation
1. Require the package
From Packagist (when published):
composer require waventra/laravel-jwt-token:^1.0
From a local path (this repository next to your Laravel app):
{
"repositories": [
{
"type": "path",
"url": "../laravel-jwt-token"
}
],
"require": {
"waventra/laravel-jwt-token": "^1.0"
}
}
Then run:
composer update waventra/laravel-jwt-token
Laravel auto-discovers the service provider and JwtAuth facade. If auto-discovery is disabled, register them manually.
Laravel 10 (config/app.php):
'providers' => [ Waventra\Jwt\JwtServiceProvider::class, ], 'aliases' => [ 'JwtAuth' => Waventra\Jwt\Facades\JwtAuth::class, ],
Laravel 11 / 12 (bootstrap/providers.php):
return [ App\Providers\AppServiceProvider::class, Waventra\Jwt\JwtServiceProvider::class, ];
2. Publish the config (optional)
php artisan vendor:publish --tag=jwt-config
This copies config/jwt.php into your application so you can change defaults without editing the package.
Publish the migrations only if you want them in your app's database/migrations folder. They already load automatically.
php artisan vendor:publish --tag=jwt-migrations
3. Run migrations
php artisan migrate
This creates:
| Table | Purpose |
|---|---|
jwt_refresh_tokens |
Hashed refresh tokens, families, device binding, rotation |
jwt_blacklist |
Revoked access-token IDs (jti) until they expire |
jwt_token_versions |
Per-user version used by logout-everywhere |
Quick start
1. Generate signing keys (recommended: RS256)
php artisan jwt:generate-keys
Keys are written to storage/jwt/private.pem and storage/jwt/public.pem. The private key is created with mode 0600. Do not commit these files or put them in a public directory.
Overwrite existing keys:
php artisan jwt:generate-keys --force
Optional passphrase (also set JWT_PASSPHRASE in .env):
php artisan jwt:generate-keys --passphrase="your-passphrase"
HMAC alternative (HS256 only):
php artisan jwt:secret
That appends JWT_SECRET to .env. The secret must be at least 32 characters. Use --force to replace an existing value. RS256 is still the recommended default.
2. Add environment variables
Minimum RS256 setup:
JWT_ALGO=RS256 JWT_PRIVATE_KEY="${APP_BASE_PATH}/storage/jwt/private.pem" JWT_PUBLIC_KEY="${APP_BASE_PATH}/storage/jwt/public.pem" JWT_ISSUER="${APP_URL}" JWT_AUDIENCE="${APP_URL}" JWT_TTL=900 JWT_REFRESH_TTL=1209600 JWT_REFRESH_ABSOLUTE_TTL=7776000
If APP_BASE_PATH is not available in your .env, use an absolute path:
JWT_PRIVATE_KEY=E:/your-app/storage/jwt/private.pem JWT_PUBLIC_KEY=E:/your-app/storage/jwt/public.pem
Leaving JWT_PRIVATE_KEY / JWT_PUBLIC_KEY unset is also valid: the package defaults to storage_path('jwt/private.pem') and storage_path('jwt/public.pem').
HS256 setup:
JWT_ALGO=HS256 JWT_SECRET=paste-the-value-from-jwt-secret JWT_ISSUER="${APP_URL}" JWT_AUDIENCE="${APP_URL}"
The none algorithm is never accepted. Verification always uses the configured algorithm, which blocks algorithm-confusion attacks.
3. Register the JWT guard
In config/auth.php:
'defaults' => [ 'guard' => 'web', 'passwords' => 'users', ], 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'jwt', 'provider' => 'users', ], ],
Keep web as the default guard for browser sessions. Use auth:api or jwt.auth on API routes.
If your user provider is not users, set:
JWT_AUTH_PROVIDER=users JWT_GUARD=api
4. Prepare the User model
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Waventra\Jwt\Concerns\HasJwtTokens; use Waventra\Jwt\Contracts\JwtSubject; class User extends Authenticatable implements JwtSubject { use HasJwtTokens; public function getJwtIdentifier(): mixed { return $this->getAuthIdentifier(); } /** * Extra JWT payload fields. Reserved claims cannot be overridden: * iss, sub, aud, exp, nbf, iat, jti, typ, ver, prv */ public function getJwtCustomClaims(): array { return [ 'email' => $this->email, ]; } }
JwtSubject is optional. Without it, the package uses getAuthIdentifier() and adds no custom claims. HasJwtTokens is also optional and adds issueJwt(), revokeAllJwtTokens(), and jwtRefreshTokens().
5. Add routes and a controller
routes/api.php:
<?php use App\Http\Controllers\AuthController; use Illuminate\Support\Facades\Route; Route::post('/auth/login', [AuthController::class, 'login']); Route::post('/auth/refresh', [AuthController::class, 'refresh']); Route::middleware('jwt.auth')->group(function () { Route::get('/me', [AuthController::class, 'me']); Route::post('/auth/logout', [AuthController::class, 'logout']); Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']); });
Laravel 11 / 12: make sure API routes are loaded in bootstrap/app.php:
->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', )
app/Http/Controllers/AuthController.php:
<?php namespace App\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Waventra\Jwt\Facades\JwtAuth; class AuthController extends Controller { public function login(Request $request): JsonResponse { $credentials = $request->validate([ 'email' => ['required', 'email'], 'password' => ['required', 'string'], ]); $pair = JwtAuth::attempt($credentials, $request); if (! $pair) { return response()->json(['message' => 'Invalid credentials.'], 401); } return JwtAuth::respond($pair); } public function refresh(Request $request): JsonResponse { return JwtAuth::respond(JwtAuth::refresh( $request->input('refresh_token'), $request, )); } public function me(Request $request): JsonResponse { return response()->json($request->user()); } public function logout(Request $request): JsonResponse { JwtAuth::revoke( $request->bearerToken(), $request->input('refresh_token'), $request, ); $response = response()->json(['message' => 'Logged out.']); return JwtAuth::forgetCookie($response); } public function logoutAll(Request $request): JsonResponse { JwtAuth::revokeAll($request->user()); $response = response()->json(['message' => 'Logged out everywhere.']); return JwtAuth::forgetCookie($response); } }
jwt.auth and auth:api are equivalent once the api guard uses the jwt driver:
Route::middleware('auth:api')->get('/me', fn () => auth('api')->user());
6. Call the API
Login:
curl -X POST http://localhost:8000/api/auth/login \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d "{\"email\":\"ada@example.com\",\"password\":\"secret\"}"
Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_expires_in": 1209600,
"refresh_token": "selector.verifier"
}
Authenticated request:
curl http://localhost:8000/api/me \ -H "Accept: application/json" \ -H "Authorization: Bearer ACCESS_TOKEN"
Refresh (send the current refresh token; the response contains a new pair):
curl -X POST http://localhost:8000/api/auth/refresh \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d "{\"refresh_token\":\"selector.verifier\"}"
Always replace the stored refresh token with the new one. Reusing the previous token after the grace window is treated as theft and revokes that login family.
Configuration
Algorithms
| Algorithm | Type | When to use |
|---|---|---|
RS256 (default) |
RSA SHA-256 | Production. Sign with private key, verify with public key. |
RS384 / RS512 |
RSA | Same as RS256 with a stronger hash. |
ES256 / ES384 / ES512 |
ECDSA | Smaller signatures than RSA. |
HS256 / HS384 / HS512 |
HMAC | Simpler local setups. Secret must be at least 32 characters. |
none is rejected. The token header alg must match JWT_ALGO.
Token lifetimes
All TTL values are seconds.
| Setting | Default | Meaning |
|---|---|---|
JWT_TTL |
900 (15 min) |
Access token lifetime. Keep this short. |
JWT_REFRESH_TTL |
1209600 (14 days) |
Sliding refresh lifetime. Reset on each successful refresh. |
JWT_REFRESH_ABSOLUTE_TTL |
7776000 (90 days) |
Hard cap. The session cannot be extended past this, even with refresh. |
JWT_LEEWAY |
0 |
Clock skew allowed for exp / nbf / iat. Keep small (0–60). |
JWT_REFRESH_GRACE |
30 |
Seconds a just-rotated refresh token can be retried (network retries). |
Environment variables
Add the JWT_* keys to your Laravel application's existing .env (and optionally .env.example). Do not replace those files — this package does not ship a root .env.example.
A commented snippet lives in resources/env.jwt.example. Copy only the keys you need into the app .env. Every lifetime is in seconds.
Signing
| Variable | Default | Description |
|---|---|---|
JWT_ALGO |
RS256 |
Algorithm used to sign and verify access tokens. The token header cannot choose a different one. Supported: RS256, RS384, RS512, ES256, ES384, ES512, HS256, HS384, HS512. none is rejected. Prefer RS256 in production. |
JWT_SECRET |
empty | Shared HMAC secret for HS256 / HS384 / HS512 only. Ignored for RS* / ES*. Must be at least 32 characters. Generate with php artisan jwt:secret. |
JWT_PRIVATE_KEY |
storage/jwt/private.pem |
Private key used to sign tokens (RS* / ES*). File path or a PEM string (\n for newlines in .env). Keep this file off the web root. |
JWT_PUBLIC_KEY |
storage/jwt/public.pem |
Public key used to verify tokens (RS* / ES*). Safe to share with other services that only need to validate JWTs. |
JWT_PASSPHRASE |
empty | Passphrase if the private key file is encrypted. Leave empty for an unencrypted key. |
JWT_KID |
jwt-key-1 |
Key id written into the JWT kid header so verifiers can pick the right public key during rotation. |
JWT_PREVIOUS_PUBLIC_KEY |
empty | Old public key kept so tokens signed before a rotation still verify until they expire. Leave empty when you are not rotating keys. |
JWT_PREVIOUS_KID |
jwt-key-0 |
kid that belongs to JWT_PREVIOUS_PUBLIC_KEY. |
Claims
| Variable | Default | Description |
|---|---|---|
JWT_ISSUER |
APP_URL |
Value stored in the iss claim and required on verify. The issuer that mints a token and the app that checks it must use the same value. |
JWT_AUDIENCE |
APP_URL |
Value stored in the aud claim and required on verify. Rejects tokens minted for a different app or environment. |
Lifetimes
| Variable | Default | Description |
|---|---|---|
JWT_TTL |
900 |
Access token lifetime (15 minutes). After this the client must refresh. Keep short. |
JWT_REFRESH_TTL |
1209600 |
Sliding refresh-token lifetime (14 days). Each successful refresh extends validity by this amount, until the absolute cap. |
JWT_REFRESH_ABSOLUTE_TTL |
7776000 |
Hard maximum age of a login session (90 days). The user must log in again after this, even if they keep refreshing. |
JWT_LEEWAY |
0 |
Extra seconds allowed on exp / nbf / iat for clock drift between servers. Use 0–60. |
JWT_REFRESH_GRACE |
30 |
Seconds the previous refresh token is still accepted after rotation, so a retried request is not treated as theft. Needs a shared cache (Redis) on multiple app servers. |
Auth wiring
| Variable | Default | Description |
|---|---|---|
JWT_AUTH_PROVIDER |
users |
Laravel auth provider name from config/auth.php. Used to load the user from the token sub claim. |
JWT_GUARD |
api |
Guard the package sets the authenticated user on. That guard must use driver => jwt. |
JWT_BLACKLIST_ENABLED |
true |
When true, logout blacklists the access token jti so it cannot be reused before it expires. When false, logout only revokes the refresh token. |
Refresh security
| Variable | Default | Description |
|---|---|---|
JWT_BIND_FINGERPRINT |
true |
Bind each refresh token to a device fingerprint (JWT_FINGERPRINT_HEADER if sent, otherwise User-Agent). A stolen token used from another device is rejected. |
JWT_BIND_IP |
false |
Also require the same client IP on refresh. Leave false unless you accept lockouts from mobile networks and VPNs. |
JWT_MAX_SESSIONS |
0 |
Max concurrent login families (devices) per user. 0 = unlimited. Oldest session is revoked when the limit is exceeded. |
JWT_REFRESH_RATE_LIMIT |
30 |
Maximum refresh attempts per IP per minute. Extra attempts return 401. |
JWT_FINGERPRINT_HEADER |
X-Device-Fingerprint |
Request header treated as the device fingerprint when present. Mobile apps should send a stable install id. |
JWT_RETENTION_DAYS |
7 |
Days to keep expired refresh rows after absolute expiry so reuse detection still has history. jwt:prune deletes older rows. |
HttpOnly refresh cookie
| Variable | Default | Description |
|---|---|---|
JWT_COOKIE_ENABLED |
false |
When true, JwtAuth::respond() sets the refresh token as an HttpOnly cookie. Use for first-party browser SPAs. |
JWT_COOKIE_NAME |
jwt_refresh |
Cookie name that stores the refresh token. |
JWT_COOKIE_DOMAIN |
empty | Cookie domain. Empty = current host. Use .example.com to share across subdomains. |
JWT_COOKIE_SECURE |
true |
Send the cookie only over HTTPS. Keep true in production. |
JWT_COOKIE_SAME_SITE |
strict |
Cookie SameSite policy: strict (same-site only), lax, or none (none requires Secure). |
JWT_COOKIE_RETURN_IN_BODY |
false |
When cookie mode is on, also put refresh_token in the JSON body. Leave false so JavaScript cannot read it. |
config/jwt.php options with no env key
Edit the published config for these:
| Key | Default | Meaning |
|---|---|---|
refresh.rotate |
true |
Issue a new refresh token on every refresh. |
refresh.reuse_detection |
true |
Revoke the whole family if a rotated token is reused after grace. |
refresh.rate_limit_decay |
60 |
Rate-limit window in seconds. |
allow_query_token |
false |
Allow ?token= as an access token. Keep off; query strings leak in logs. |
required_claims |
see config | Claims that must exist on every access token. |
Usage
Login with email and password
$pair = JwtAuth::attempt($request->only('email', 'password'), $request); if (! $pair) { return response()->json(['message' => 'Invalid credentials.'], 401); } return JwtAuth::respond($pair);
Pass the $request so device fingerprint and IP can be bound to the refresh token.
Issue tokens after your own login logic
Use this for OTP, social login, or a custom user lookup:
$user = User::query()->where('email', $request->email)->firstOrFail(); $pair = JwtAuth::issue($user, $request); return JwtAuth::respond($pair);
Or from the model if you use HasJwtTokens:
$pair = $user->issueJwt($request);
Refresh
$pair = JwtAuth::refresh($request->input('refresh_token'), $request); return JwtAuth::respond($pair);
If cookie mode is enabled, omit the body token and the package reads jwt_refresh (or JWT_COOKIE_NAME) automatically:
return JwtAuth::respond(JwtAuth::refresh(null, $request));
Refresh is rate-limited per IP (JWT_REFRESH_RATE_LIMIT, default 30 per minute).
Authenticated routes
Send:
Authorization: Bearer {access_token} Accept: application/json
Then:
$user = $request->user(); // after jwt.auth or auth:api $user = auth('api')->user(); $user = JwtAuth::user(); // null if missing/invalid $ok = JwtAuth::check(); $user = JwtAuth::authenticate(); // throws on failure
Logout (this device)
Blacklists the current access token and revokes that refresh-token family:
JwtAuth::revoke($request->bearerToken(), $request->input('refresh_token'), $request);
Logout everywhere
Increments the user's token version (all access tokens become invalid immediately) and revokes every refresh token:
JwtAuth::revokeAll($request->user()); // or $request->user()->revokeAllJwtTokens();
Custom claims
Implement JwtSubject::getJwtCustomClaims(). Reserved names are ignored if you try to set them.
Concurrent sessions
JWT_MAX_SESSIONS=3
0 means unlimited. When the limit is exceeded, the oldest refresh-token family is revoked.
Device fingerprint
Enabled by default. The refresh token is bound to:
- Header
X-Device-Fingerprintif present, otherwise - The
User-Agentstring
Send a stable device id from mobile apps:
X-Device-Fingerprint: your-install-id
Rename the header with JWT_FINGERPRINT_HEADER. Disable with JWT_BIND_FINGERPRINT=false if User-Agent changes too often (some browsers).
IP binding (JWT_BIND_IP=true) is stricter and can lock out mobile users on carrier NAT. Leave it off unless you need it.
HttpOnly refresh cookies
Use this for first-party browser SPAs so JavaScript never sees the refresh token.
JWT_COOKIE_ENABLED=true JWT_COOKIE_SECURE=true JWT_COOKIE_SAME_SITE=strict JWT_COOKIE_RETURN_IN_BODY=false
JwtAuth::respond($pair) then:
- Sets an HttpOnly, Secure, SameSite cookie
- Omits
refresh_tokenfrom the JSON body - Still returns
access_tokenin JSON (keep it in memory, notlocalStorage)
Cross-site frontends need CORS credentials, SameSite=none, and HTTPS. Prefer strict or lax for same-site apps.
On logout, call JwtAuth::forgetCookie($response).
Never put access or refresh tokens in localStorage.
Client integration
Recommended browser flow:
- Login → store
access_tokenin memory; store refresh token in memory or let the HttpOnly cookie hold it. - Call APIs with
Authorization: Bearer …. - When a request returns
401witherrortoken_expired, call/api/auth/refreshonce, replace tokens, retry the original request. - If refresh fails, send the user to login.
JavaScript (body refresh token):
const login = await fetch('/api/auth/login', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const tokens = await login.json(); let accessToken = tokens.access_token; let refreshToken = tokens.refresh_token; const me = await fetch('/api/me', { headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, }); const refreshed = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); const next = await refreshed.json(); accessToken = next.access_token; refreshToken = next.refresh_token;
With HttpOnly cookies, use credentials: 'include' and do not send refresh_token in the body.
API reference
Facade: Waventra\Jwt\Facades\JwtAuth
Container: app(\Waventra\Jwt\JwtAuth::class)
| Method | Returns | Description |
|---|---|---|
attempt(array $credentials, ?Request $request = null) |
TokenPair|null |
Validate credentials via the auth provider, then issue tokens. |
issue(Authenticatable $user, ?Request $request = null) |
TokenPair |
Issue a new access + refresh pair. |
refresh(?string $refreshToken = null, ?Request $request = null) |
TokenPair |
Rotate refresh token and issue a new access token. |
authenticate(?string $jwt = null, ?Request $request = null) |
Authenticatable |
Validate the access token or throw. |
authenticateFromRequest(?Request $request = null) |
Authenticatable |
Same, always from the current request. |
user() |
Authenticatable|null |
Current user, or null if the token is missing/invalid. |
check() |
bool |
Whether a valid access token is present. |
revoke(?string $jwt = null, ?string $refreshToken = null, ?Request $request = null) |
void |
Logout this device. |
revokeAll(Authenticatable $user) |
void |
Logout every device. |
respond(TokenPair $pair, int $status = 200) |
JsonResponse |
JSON body plus optional refresh cookie. |
forgetCookie(JsonResponse $response) |
JsonResponse |
Expire the refresh cookie. |
extractAccessToken(Request $request) |
string|null |
Read Authorization: Bearer. |
extractRefreshToken(Request $request) |
string|null |
Read body refresh_token or the cookie. |
TokenPair public properties: accessToken, refreshToken, expiresIn, refreshExpiresIn, tokenType. toArray() is the JSON shape returned by respond().
HasJwtTokens on the user:
| Method | Description |
|---|---|
issueJwt(?Request $request = null) |
Same as JwtAuth::issue($this). |
revokeAllJwtTokens() |
Same as JwtAuth::revokeAll($this). |
jwtRefreshTokens() |
Morph-many relation to stored refresh rows. |
Artisan commands
| Command | Description |
|---|---|
php artisan jwt:generate-keys |
Create RSA keys in storage/jwt/. Options: --force, --bits=4096, --passphrase= |
php artisan jwt:secret |
Write JWT_SECRET into .env for HS256. Option: --force |
php artisan jwt:prune |
Delete expired blacklist rows and refresh tokens past retention |
Schedule pruning in routes/console.php (Laravel 11+) or app/Console/Kernel.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('jwt:prune')->daily();
Events
Listen in App\Providers\AppServiceProvider or an event service provider.
| Event | When |
|---|---|
Waventra\Jwt\Events\TokenIssued |
Login / issue() |
Waventra\Jwt\Events\TokenRefreshed |
Successful refresh |
Waventra\Jwt\Events\TokenRevoked |
Logout (scope is current or all) |
Waventra\Jwt\Events\RefreshTokenReuseDetected |
Stolen/replayed refresh token; family already revoked |
Reuse still returns a generic Invalid refresh token. to the client. Use the event for alerts and logs. Do not log token values.
use Waventra\Jwt\Events\RefreshTokenReuseDetected; Event::listen(RefreshTokenReuseDetected::class, function (RefreshTokenReuseDetected $event) { logger()->warning('Refresh token reuse', [ 'family_id' => $event->token->family_id, 'user_id' => $event->token->tokenable_id, 'ip' => $event->request->ip(), ]); });
Exceptions
JSON requests (and api/* routes) render as:
{
"message": "Access token has expired.",
"error": "token_expired_exception"
}
HTTP status is 401, except configuration errors which are 500.
| Exception | Typical cause |
|---|---|
TokenInvalidException |
Missing, malformed, wrong issuer/audience, or invalidated token |
TokenExpiredException |
Access token past exp |
TokenBlacklistedException |
Access token revoked by logout |
RefreshTokenException |
Missing, expired, revoked, rate-limited, or device-mismatch refresh token |
TokenTheftException |
Refresh token reused after rotation (message is still generic) |
ConfigurationException |
Missing keys, short HMAC secret, or unsupported algorithm |
Security model
| Control | Default | Purpose |
|---|---|---|
| Short access TTL | 15 minutes | Limits damage if a JWT is stolen |
| Opaque refresh tokens | HMAC-SHA256 hashes | Secrets are never stored in plaintext |
| Selector + verifier | selector.verifier |
Fast lookup; verifier compared with hash_equals |
| Rotation | on | Each refresh issues a new refresh token |
| Reuse detection | on | Using a rotated token revokes the whole family |
| Grace period | 30 seconds | Safe retry if the client did not persist the new token |
| Absolute session TTL | 90 days | Hard cap even with continuous refresh |
| Blacklist | on | Logout revokes the current access jti |
| Token version | on | revokeAll() kills every device immediately |
| Issuer / audience | required | Tokens from another app are rejected |
| Device fingerprint | on | Refresh bound to User-Agent or X-Device-Fingerprint |
| IP binding | off | Optional stricter binding |
| HttpOnly refresh cookie | off | Keep refresh tokens out of JavaScript |
A stolen database dump of jwt_refresh_tokens is not enough to mint refresh tokens. Production should use a shared cache:
CACHE_STORE=redis
Access-token claims
| Claim | Meaning |
|---|---|
iss / aud |
Issuer and audience (JWT_ISSUER / JWT_AUDIENCE) |
iat / nbf / exp |
Issued at, not before, expiry |
sub |
User id |
jti |
Unique id used for blacklist |
typ |
Always access |
ver |
Token version (logout everywhere) |
prv |
Hash of the authenticatable class (prevents mixing user models) |
Rotating RSA keys
- Generate a new pair (
jwt:generate-keys --forceafter backing up the old public key). - Keep the old public key in
JWT_PREVIOUS_PUBLIC_KEYand its id inJWT_PREVIOUS_KID. - Point
JWT_PRIVATE_KEY/JWT_PUBLIC_KEY/JWT_KIDat the new pair. - After all access tokens signed with the old key have expired (
JWT_TTL), you can drop the previous public key.
Maintenance
Do not commit storage/jwt/*.pem or .env. Restrict the private key file. Serve the API over HTTPS.
Prune stale rows daily with jwt:prune. Retention is JWT_RETENTION_DAYS (default 7 days after absolute expiry) so reuse detection still has history.
Troubleshooting
| Symptom | What to check |
|---|---|
JWT algorithm [NONE] is not allowed |
JWT_ALGO must be one of the supported values. |
JWT_SECRET must be at least 32 characters |
Run php artisan jwt:secret or lengthen the secret. |
A required JWT key is missing / key file errors |
Generate keys and set paths. PEM in .env may use \n for newlines. |
Invalid token issuer / audience |
JWT_ISSUER and JWT_AUDIENCE must match the values used when the token was issued. Usually both are APP_URL. |
Access token has expired |
Expected after JWT_TTL. Call refresh. |
Refresh token device mismatch |
Send the same User-Agent or X-Device-Fingerprint as at login, or disable fingerprint binding. |
Too many refresh attempts |
Per-IP rate limit. Raise JWT_REFRESH_RATE_LIMIT or wait. |
| Refresh retry logs the user out | Grace cache is per server. Use Redis. Increase JWT_REFRESH_GRACE slightly if needed. |
auth('api')->user() is null |
Guard driver must be jwt, and the request must include Authorization: Bearer. |
| RSA generation fails on Windows | The package ships resources/openssl.cnf. You can also set the OPENSSL_CONF environment variable to a valid OpenSSL config file. |
License
MIT