Hasina Razafintsalama

Hasina RAZAFINTSALAMA

← Back to Blog
Backend

JWT Authentication in Laravel: A Complete Guide

Sanctum covers most SPA and mobile auth. JWT is the right tool when independent services must verify a token without a shared session store. Here is how to implement it properly in Laravel, from config to refresh tokens and revocation.

2026-07-16·12 min

Sanctum handles the majority of Laravel auth needs cleanly: SPA tokens, mobile tokens, simple API tokens. JWT solves a narrower problem, verifying a token without hitting a shared database or session store, which matters once several independent services need to trust the same token. If you do not have that problem, JWT adds complexity for nothing. This guide covers when JWT is the right call and how to build it end to end.

JWT vs Sanctum vs Passport

A Sanctum token is a database row: every request looks it up, which also makes revocation an instant delete. A JWT is self-contained, its signature alone proves validity, so any service with the key can verify it offline. Passport is a full OAuth2 server, which you only need when third-party clients request access on behalf of your users.

SanctumJWTPassport
Token storageDatabase rowNothing server-sideDatabase (OAuth2 tables)
RevocationInstant, delete the rowHard, needs a blocklistInstant, revoke the token
Offline verificationNoYes, by any service with the keyNo
OAuth2 serverNoNoYes
Setup effortMinimalModerateHigh
Best forOne app plus its own SPA or mobile clientSeveral independent services verifying one tokenThird-party API clients, OAuth2 flows

If you have one Laravel app talking to a single frontend, Sanctum is simpler and safer. Reach for JWT only when independent services must verify tokens without a shared session store. If in doubt, you want Sanctum.

Anatomy of a JWT

A JWT has three parts: a header (signing algorithm), a payload of claims (subject, expiration, issuer, audience, custom data), and a signature. The signature prevents tampering, but the payload is only base64-encoded, not encrypted. Never put secrets or sensitive data in claims, anyone holding the token can decode and read them.

Installing and configuring the package

The original tymon/jwt-auth has been quiet for a while. The maintained community fork php-open-source-saver/jwt-auth is a drop-in replacement that tracks current Laravel and PHP. Install it, publish the config, generate the signing secret, point the api guard at the jwt driver, and have the User model implement JWTSubject.

php
// config/auth.php
'guards' => [
    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

// app/Models/User.php
class User extends Authenticatable implements JWTSubject
{
    public function getJWTIdentifier(): mixed
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims(): array
    {
        return ['role' => $this->role];
    }
}

Login, me, logout

php
class AuthController extends Controller
{
    public function login(LoginRequest $request)
    {
        if (! $token = auth('api')->attempt($request->validated())) {
            return response()->json(['message' => 'Invalid credentials'], 401);
        }

        return $this->tokenResponse($token);
    }

    public function me()
    {
        return new UserResource(auth('api')->user());
    }

    public function logout()
    {
        auth('api')->logout(); // invalidates the current token
        return response()->noContent();
    }

    private function tokenResponse(string $token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth('api')->factory()->getTTL() * 60,
        ]);
    }
}

Protecting routes and reading the user

Group protected routes under the auth:api middleware. Inside a controller, the api guard resolves the authenticated model from the token, with no database lookup unless you ask for one.

php
// routes/api.php
Route::post('auth/login', [AuthController::class, 'login']);

Route::middleware('auth:api')->group(function () {
    Route::get('auth/me', [AuthController::class, 'me']);
    Route::post('auth/refresh', [AuthController::class, 'refresh']);
    Route::post('auth/logout', [AuthController::class, 'logout']);
    Route::apiResource('orders', OrderController::class);
});

Refresh tokens done right

Keep the access token short-lived (15 to 60 minutes) and pair it with a longer-lived refresh token. Rotate the refresh token on every use, issuing a new one and invalidating the old, so a stolen refresh token works once before the legitimate user's next refresh reveals the theft.

php
public function refresh()
{
    $newToken = auth('api')->refresh(); // old token is blacklisted, new one issued

    return $this->tokenResponse($newToken);
}

Revocation with a Redis blocklist

A JWT cannot be un-issued, so forced logout and "log out everywhere" need a blocklist. Store the token ID (the jti claim) in Redis with a TTL equal to the token's remaining lifetime, so the entry disappears on its own once the token would have expired anyway. Check the blocklist in a middleware after auth:api.

php
class RejectBlockedTokens
{
    public function handle(Request $request, Closure $next)
    {
        $jti = auth('api')->payload()->get('jti');

        if (Redis::exists("jwt:blocked:{$jti}")) {
            return response()->json(['message' => 'Token revoked'], 401);
        }

        return $next($request);
    }
}

Security pitfalls to avoid

  • Algorithm confusion: pin the expected algorithm server-side, never trust the alg header from the token itself.
  • Do not store the token in localStorage for browser apps, use an httpOnly, Secure, SameSite cookie so an XSS bug cannot steal it.
  • Validate exp, iss and aud, not just the signature, so a token minted for another service is rejected.
  • Keep the payload small, it travels on every request and it is readable by anyone.
  • Serve the API over HTTPS only, and rotate the signing key on a schedule with an overlap window.

Testing JWT auth

php
public function test_protected_route_requires_a_valid_token(): void
{
    $user = User::factory()->create();
    $token = auth('api')->login($user);

    $this->getJson('/api/auth/me')->assertUnauthorized();

    $this->withToken($token)
        ->getJson('/api/auth/me')
        ->assertOk()
        ->assertJsonPath('data.id', $user->id);
}

FAQ

JWT or Sanctum for a Laravel API?
Sanctum for a single Laravel app talking to your own SPA or mobile client: it is simpler, and revocation is a database delete. JWT only when several independent services must verify the same token without sharing a session store or calling back to your database. If you are unsure, you want Sanctum.
Where should the JWT be stored on the client?
In an httpOnly, Secure, SameSite cookie for browser apps, so JavaScript cannot read it and an XSS bug cannot steal it. Native mobile apps use the platform secure storage such as Keychain or Keystore. Never localStorage.
How do I revoke a JWT before it expires?
You cannot un-issue it, so keep access tokens short (15 to 60 minutes) and maintain a blocklist of token IDs (the jti claim) in Redis with a TTL equal to the token's remaining lifetime. Check the blocklist in your auth middleware for forced logout and compromised tokens.
What access token lifetime should I use?
15 to 60 minutes for the access token, paired with a refresh token of days to weeks. Short access tokens limit the damage of a leak; the refresh token, rotated on every use, keeps the user logged in without a fresh password prompt.
Is tymon/jwt-auth still maintained?
The original package has been quiet for a while. The community fork php-open-source-saver/jwt-auth is the actively maintained drop-in replacement, with support for current Laravel and PHP versions, and the same API.

JWT solves one specific problem: stateless verification across independent services. Implemented with short access tokens, rotating refresh tokens, a Redis blocklist and httpOnly cookies, it is solid. If that is not your architecture, Sanctum's simplicity and instant revocation win every time.

Need help with this topic? REST API Design

Discover this service