waventra/laravel-jwt-token

Secure JWT authentication for Laravel with rotating refresh tokens, reuse detection, and revocation.

Maintainers

Package info

github.com/waventra/laravel-jwt-token

pkg:composer/waventra/laravel-jwt-token

Transparency log

Statistics

Installs: 4

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-09-01 02:18 UTC

This package is auto-updated.

Last update: 2026-09-01 02:30:48 UTC


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

  • PHP 8.2+ with ext-json and ext-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 060.
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:

  1. Header X-Device-Fingerprint if present, otherwise
  2. The User-Agent string

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_token from the JSON body
  • Still returns access_token in JSON (keep it in memory, not localStorage)

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:

  1. Login → store access_token in memory; store refresh token in memory or let the HttpOnly cookie hold it.
  2. Call APIs with Authorization: Bearer ….
  3. When a request returns 401 with error token_expired, call /api/auth/refresh once, replace tokens, retry the original request.
  4. 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

  1. Generate a new pair (jwt:generate-keys --force after backing up the old public key).
  2. Keep the old public key in JWT_PREVIOUS_PUBLIC_KEY and its id in JWT_PREVIOUS_KID.
  3. Point JWT_PRIVATE_KEY / JWT_PUBLIC_KEY / JWT_KID at the new pair.
  4. 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