← Back to Blog
|9 min read|Vipula Saman Anandapiya

Laravel JWT Tokens: Complete Guide (Access + Refresh Tokens)

Laravel JWT Tokens: Complete Guide (Access + Refresh Tokens)

Laravel JWT Tokens: Complete Guide (Installation, Setup, Refresh, React/Vue Usage)

JWT auth is great for SPAs and mobile apps, but a single long-lived token is risky. A better approach is using two tokens:

  • Access token (short-lived): sent on every API request.
  • Refresh token (long-lived): used only to obtain a new access token when it expires.

This guide shows a practical Laravel implementation with a refresh endpoint, token rotation, and front-end usage for React/Vue.


1) Why two tokens?

The access token should expire quickly (e.g. 15 minutes). If it leaks, the window of abuse is small. The refresh token lives longer (e.g. 7–30 days) and is stored more safely (ideally as an HttpOnly cookie), and is used to mint new access tokens without forcing the user to log in again.

Recommended security model

  • Access token in memory (or short-lived storage) and sent as Authorization: Bearer ...
  • Refresh token stored as HttpOnly cookie (not accessible to JS)
  • Refresh token rotation: each refresh returns a new refresh token and invalidates the old one
  • Server-side storage of refresh token identifiers (hashed) to enable revocation

2) Installation & setup (Laravel)

We’ll use tymon/jwt-auth for access tokens, and implement refresh tokens ourselves (stored in DB).

2.1 Install JWT package

composer require tymon/jwt-auth

2.2 Publish config & generate secret

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
php artisan jwt:secret

2.3 Configure guards (auth.php)

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

2.4 User model implements JWTSubject

// app/Models/User.php
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
}

3) Designing the access + refresh token flow

3.1 Token lifetimes

Set a short TTL for access tokens:

// config/jwt.php
'ttl' => 15, // minutes

Refresh tokens are not managed by jwt-auth here. We’ll store them in a database table with an expiry date.

3.2 The refresh flow (high-level)

  1. User logs in with email/password.
  2. Server returns access token in JSON and sets refresh token as HttpOnly cookie.
  3. Client calls APIs with access token.
  4. If API returns 401 (expired access token), client calls /auth/refresh.
  5. Server validates refresh token, rotates it, returns a new access token (and sets a new refresh cookie).
  6. Client retries the original request with the new access token.

4) Implementing refresh tokens in Laravel

4.1 Create a refresh_tokens table

php artisan make:migration create_refresh_tokens_table
// database/migrations/xxxx_xx_xx_create_refresh_tokens_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('refresh_tokens', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();

            // Store a hash of the refresh token for security
            $table->string('token_hash', 64)->unique();

            // Optional: track device/session
            $table->string('user_agent')->nullable();
            $table->string('ip_address')->nullable();

            $table->timestamp('expires_at');
            $table->timestamp('revoked_at')->nullable();

            // Rotation support
            $table->unsignedBigInteger('replaced_by_id')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('refresh_tokens');
    }
};
php artisan migrate

4.2 Create a RefreshToken model

php artisan make:model RefreshToken
// app/Models/RefreshToken.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class RefreshToken extends Model
{
    protected $fillable = [
        'user_id',
        'token_hash',
        'user_agent',
        'ip_address',
        'expires_at',
        'revoked_at',
        'replaced_by_id',
    ];

    protected $casts = [
        'expires_at' => 'datetime',
        'revoked_at' => 'datetime',
    ];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function isValid(): bool
    {
        return is_null($this->revoked_at) && $this->expires_at->isFuture();
    }
}

4.3 A small service to issue & rotate refresh tokens

// app/Services/RefreshTokenService.php
namespace App\Services;

use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class RefreshTokenService
{
    public function createForUser(User $user, ?string $userAgent, ?string $ip): array
    {
        $plain = Str::random(64); // refresh token shown to client (cookie)
        $hash = hash('sha256', $plain);

        $token = RefreshToken::create([
            'user_id' => $user->id,
            'token_hash' => $hash,
            'user_agent' => $userAgent,
            'ip_address' => $ip,
            'expires_at' => now()->addDays(14),
        ]);

        return [$plain, $token];
    }

    public function rotate(RefreshToken $current, ?string $userAgent, ?string $ip): array
    {
        // revoke current
        $current->update(['revoked_at' => now()]);

        // create replacement
        [$plain, $new] = $this->createForUser($current->user, $userAgent, $ip);

        $current->update(['replaced_by_id' => $new->id]);

        return [$plain, $new];
    }

    public function findByPlainToken(string $plain): ?RefreshToken
    {
        $hash = hash('sha256', $plain);
        return RefreshToken::where('token_hash', $hash)->first();
    }
}

4.4 Auth controller: login, refresh, logout

php artisan make:controller AuthController
// app/Http/Controllers/AuthController.php
namespace App\Http\Controllers;

use App\Services\RefreshTokenService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Cookie;

class AuthController extends Controller
{
    public function __construct(private RefreshTokenService $refreshTokens) {}

    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (! $accessToken = Auth::guard('api')->attempt($credentials)) {
            return response()->json(['message' => 'Invalid credentials'], 401);
        }

        $user = Auth::guard('api')->user();
        [$refreshPlain] = $this->refreshTokens->createForUser(
            $user,
            $request->userAgent(),
            $request->ip()
        );

        return response()->json([
            'access_token' => $accessToken,
            'token_type' => 'Bearer',
            'expires_in' => Auth::guard('api')->factory()->getTTL() * 60,
            'user' => $user,
        ])->withCookie($this->refreshCookie($refreshPlain));
    }

    public function refresh(Request $request)
    {
        $plain = $request->cookie('refresh_token');
        if (! $plain) {
            return response()->json(['message' => 'Missing refresh token'], 401);
        }

        $stored = $this->refreshTokens->findByPlainToken($plain);
        if (! $stored || ! $stored->isValid()) {
            return response()->json(['message' => 'Invalid refresh token'], 401);
        }

        // Rotate refresh token
        [$newRefreshPlain] = $this->refreshTokens->rotate(
            $stored,
            $request->userAgent(),
            $request->ip()
        );

        // Mint a new access token for the same user
        $accessToken = Auth::guard('api')->login($stored->user);

        return response()->json([
            'access_token' => $accessToken,
            'token_type' => 'Bearer',
            'expires_in' => Auth::guard('api')->factory()->getTTL() * 60,
        ])->withCookie($this->refreshCookie($newRefreshPlain));
    }

    public function logout(Request $request)
    {
        // Invalidate access token (optional; depends on blacklist settings)
        try {
            Auth::guard('api')->logout();
        } catch (\Throwable $e) {
            // ignore
        }

        // Revoke refresh token in DB if present
        $plain = $request->cookie('refresh_token');
        if ($plain) {
            $stored = $this->refreshTokens->findByPlainToken($plain);
            if ($stored) {
                $stored->update(['revoked_at' => now()]);
            }
        }

        return response()->json(['message' => 'Logged out'])
            ->withCookie(Cookie::create('refresh_token')->withValue('')->withExpires(0));
    }

    private function refreshCookie(string $refreshPlain): Cookie
    {
        // Note: set Secure=true in production (HTTPS)
        return Cookie::create('refresh_token')
            ->withValue($refreshPlain)
            ->withHttpOnly(true)
            ->withSecure(app()->environment('production'))
            ->withSameSite('lax')
            ->withPath('/api/auth')
            ->withExpires(now()->addDays(14));
    }
}

4.5 Routes

// routes/api.php
use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;

Route::prefix('auth')->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::post('/refresh', [AuthController::class, 'refresh']);
    Route::post('/logout', [AuthController::class, 'logout']);
});

Route::middleware('auth:api')->get('/me', function () {
    return auth('api')->user();
});

5) How refreshing works (step-by-step)

Here’s what happens when the access token expires:

  1. The API returns 401 Unauthorized (or a JWT expired error).
  2. The front-end calls POST /api/auth/refresh.
  3. The browser automatically includes the refresh_token cookie.
  4. The server looks up the refresh token hash in the DB, checks it’s not revoked and not expired.
  5. The server rotates it: revokes the old record, creates a new one, sets a new cookie.
  6. The server returns a new access token in JSON.
  7. The front-end retries the original request using the new access token.

6) React/Vue usage (Axios example)

The key detail: refresh uses cookies, so you must enable withCredentials and configure CORS accordingly.

6.1 Axios client with automatic refresh

// api.js
import axios from "axios";

export const api = axios.create({
  baseURL: "http://localhost:8000/api",
  withCredentials: true, // needed so refresh cookie is sent
});

let accessToken = null;

export function setAccessToken(token) {
  accessToken = token;
}

api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

let refreshingPromise = null;

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config;

    if (error.response?.status === 401 && !original._retry) {
      original._retry = true;

      // prevent multiple simultaneous refresh calls
      if (!refreshingPromise) {
        refreshingPromise = api.post("/auth/refresh")
          .then((r) => {
            setAccessToken(r.data.access_token);
            return r.data.access_token;
          })
          .finally(() => {
            refreshingPromise = null;
          });
      }

      const newToken = await refreshingPromise;
      original.headers.Authorization = `Bearer ${newToken}`;
      return api(original);
    }

    return Promise.reject(error);
  }
);

6.2 Login usage

// login.js
import { api, setAccessToken } from "./api";

export async function login(email, password) {
  const res = await api.post("/auth/login", { email, password });
  setAccessToken(res.data.access_token);
  return res.data.user;
}

6.3 Vue/React notes

  • Store the access token in memory (state management) if possible.
  • If you must persist it, prefer short-lived storage and consider XSS hardening.
  • Refresh token remains in HttpOnly cookie; JS cannot read it (by design).

7) CORS & cookie settings (important)

If your SPA runs on a different domain/port, configure Laravel CORS to allow credentials.

// config/cors.php
return [
  'paths' => ['api/*'],
  'allowed_methods' => ['*'],
  'allowed_origins' => ['http://localhost:5173', 'http://localhost:3000'],
  'allowed_headers' => ['*'],
  'supports_credentials' => true,
];

For cross-site cookies (different domain), you’ll likely need SameSite=None and Secure=true. Adjust the cookie builder accordingly.


8) Common pitfalls & best practices

  • Don’t store refresh tokens in localStorage if you can avoid it. Prefer HttpOnly cookies.
  • Rotate refresh tokens to reduce replay risk.
  • Hash refresh tokens in DB so a DB leak doesn’t expose live tokens.
  • Revoke on logout and consider a “revoke all sessions” endpoint.
  • Rate-limit the refresh endpoint to reduce abuse.

9) Quick API summary

  • POST /api/auth/login → returns access token + sets refresh cookie
  • POST /api/auth/refresh → validates refresh cookie, rotates, returns new access token
  • POST /api/auth/logout → revokes refresh token + clears cookie
  • Protected routes use auth:api and require Authorization: Bearer <access>

With this structure, you get short-lived access tokens for safety and long-lived refresh tokens for a smooth user experience—without constant re-login.