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

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

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

Laravel JWT Tokens: The Complete, Step-by-Step Guide

JSON Web Tokens (JWT) are a popular way to secure APIs with stateless authentication. In Laravel, JWT-based auth is commonly used for SPAs (React/Vue), mobile apps, and third-party API consumers. This guide walks through packages, full installation and setup, token creation, refresh flow, and practical usage, including simple React and Vue samples.

What is a JWT (and how it works in Laravel)?

A JWT is a compact string that contains a set of claims (data) signed by the server. Clients store the token and send it with each request (usually in the Authorization header). The server verifies the signature and reads the claims to identify the user.

JWT structure

A JWT has three base64url-encoded parts separated by dots:

  • Header: token type and signing algorithm (e.g., HS256)
  • Payload: claims like sub (subject/user id), iat (issued at), exp (expires at)
  • Signature: verifies the token wasn’t altered

Why JWT is useful

  • Stateless: no server session storage required for basic validation
  • Portable: works across web, mobile, and third parties
  • Fast: signature verification is typically quick

Common caveats

  • JWTs are bearer tokens: anyone who has the token can use it until it expires.
  • Use HTTPS and keep token lifetimes short; use refresh tokens/refresh flow.
  • JWT does not automatically provide “logout everywhere” unless you implement blacklisting/rotation.

Laravel Packages for JWT Authentication

Here are the most common JWT options in the Laravel ecosystem:

1) tymon/jwt-auth (most popular)

  • Pros: widely used, integrates with Laravel guards, supports refresh tokens, blacklisting, custom claims.
  • Cons: you must understand config/guards; some advanced security patterns require extra design.
  • Best for: SPA/mobile API auth with classic “access token + refresh” flow.

2) Laravel Sanctum (not JWT, but often the better choice)

  • Pros: first-party, simple for SPAs (cookie-based) and personal access tokens.
  • Cons: not JWT; if you specifically need JWT interoperability, this may not fit.
  • Best for: Laravel-first SPAs, same-domain apps, simpler token auth without JWT.

3) Laravel Passport (OAuth2; not JWT-focused for SPA)

  • Pros: full OAuth2 implementation; strong for third-party integrations.
  • Cons: heavier setup; not strictly “JWT auth” in the typical SPA sense (though tokens may be JWT depending on configuration).
  • Best for: OAuth2 needs (authorization code, client credentials, etc.).

This tutorial will implement JWT using tymon/jwt-auth, because it’s the most common JWT package for Laravel APIs.

Full Step-by-Step JWT Installation & Setup (Laravel + tymon/jwt-auth)

Prerequisites

  • Laravel project (Laravel 9/10/11 all commonly work; always confirm package compatibility)
  • PHP and Composer installed
  • Users table and User model (default Laravel auth scaffolding is fine)
  • API routes (typically in routes/api.php)

Step 1: Install the package

composer require tymon/jwt-auth

Step 2: Publish the config

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

This creates config/jwt.php.

Step 3: Generate the JWT secret

php artisan jwt:secret

This adds JWT_SECRET=... to your .env. The secret is used to sign tokens (HS256 by default). Keep it private and rotate it carefully (rotating invalidates existing tokens).

Step 4: Configure the User model

Your User model must implement Tymon\JWTAuth\Contracts\JWTSubject.

<?php

namespace App\Models;

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

class User extends Authenticatable implements JWTSubject
{
    use Notifiable;

    // ... your fillable/hidden/casts

    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

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

Step 5: Configure the auth guard to use JWT

Open config/auth.php and set your API guard driver to jwt. A common setup:

// config/auth.php

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

Ensure your providers are configured normally (default Laravel config is fine).

Step 6: (Optional but recommended) Adjust JWT TTL and refresh TTL

You can control how long access tokens last and how long refresh is allowed. In .env:

JWT_TTL=15
JWT_REFRESH_TTL=20160

Typical values: JWT_TTL in minutes (e.g., 15 minutes). JWT_REFRESH_TTL in minutes (e.g., 20160 minutes = 14 days).

If these env vars aren’t present, you can set them in config/jwt.php under ttl and refresh_ttl.

Step 7: Create an Auth Controller (login, me, refresh, logout)

Create a controller, for example app/Http/Controllers/AuthController.php:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    public function __construct()
    {
        // Protect everything except login
        $this->middleware('auth:api', ['except' => ['login']]);
    }

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

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

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

    public function me()
    {
        return response()->json(Auth::guard('api')->user());
    }

    public function refresh()
    {
        $token = Auth::guard('api')->refresh();
        return $this->respondWithToken($token);
    }

    public function logout()
    {
        Auth::guard('api')->logout();
        return response()->json(['message' => 'Successfully logged out']);
    }

    protected function respondWithToken(string $token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => Auth::guard('api')->factory()->getTTL() * 60,
        ]);
    }
}

Step 8: Add routes

In routes/api.php:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;

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

Step 9: Test the flow with cURL (or Postman)

Login

curl -X POST http://localhost:8000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"secret"}'

Response example:

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "token_type": "bearer",
  "expires_in": 900
}

Call a protected endpoint (/me)

curl http://localhost:8000/api/auth/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Refresh token

curl -X POST http://localhost:8000/api/auth/refresh \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Logout (invalidate token)

curl -X POST http://localhost:8000/api/auth/logout \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

How to Create a JWT Token in Laravel (Under the Hood)

In the controller above, the token is created using: Auth::guard('api')->attempt($credentials). This does two things:

  1. Validates the credentials against your user provider (usually the users table).
  2. If valid, generates a signed JWT containing the user identifier (sub) and timestamps (iat, exp).

Other ways to issue tokens

Sometimes you want to issue a token for a known user (e.g., after registration, admin impersonation, or SSO callback). You can generate a token directly from a User instance:

$token = Auth::guard('api')->login($user);

Or using the JWTAuth facade (if you prefer):

use Tymon\JWTAuth\Facades\JWTAuth;

$token = JWTAuth::fromUser($user);

How the token is used by the client

The client stores the token (commonly in memory or secure storage) and sends it on every request:

Authorization: Bearer <token>

Laravel’s JWT guard reads the header, verifies the signature, checks expiration, and sets the authenticated user for the request.

Token Refresh: How It Works and Why It Improves Security

Why refresh tokens (or refresh flow) exists

If you issue long-lived access tokens (e.g., 30 days), then a stolen token can be abused for a long time. If you issue short-lived tokens (e.g., 10–15 minutes), you reduce the blast radius—but users would be forced to log in constantly.

The refresh flow solves this by:

  • Keeping access tokens short-lived (better security)
  • Allowing the client to silently obtain a new access token without re-entering credentials

How refresh works with tymon/jwt-auth

When you call Auth::guard('api')->refresh(), the package issues a new token with a new expiration. This is only allowed within the configured refresh_ttl window.

Important security notes about refresh

  • Short access TTL: Set JWT_TTL to something small (10–30 minutes is common).
  • Refresh window: Set JWT_REFRESH_TTL to a reasonable period (days/weeks) depending on your risk model.
  • Token invalidation/blacklisting: For “logout” and high security, enable blacklisting so old tokens can be invalidated.
  • Rotation strategy: Many systems rotate refresh tokens (server stores refresh token identifiers). With tymon/jwt-auth, refresh is typically handled by issuing a new access token; consider blacklisting to reduce replay risk.

Enabling blacklisting (recommended for logout support)

In config/jwt.php, ensure blacklisting is enabled:

'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),

And in .env:

JWT_BLACKLIST_ENABLED=true

Blacklisting requires a cache store (Redis is common). Configure your cache properly in .env:

CACHE_STORE=redis

With blacklisting on, calling logout() invalidates the token so it can’t be used again.

Practical Usage Patterns (Real-World API Design)

1) Protecting routes with JWT middleware

The simplest approach is using the auth:api middleware. Example:

use Illuminate\Support\Facades\Route;

Route::middleware('auth:api')->get('/profile', function () {
    return response()->json([
        'user' => auth('api')->user(),
    ]);
});

2) Role/permission checks

JWT handles authentication (who you are). Authorization (what you can do) is separate. Common approach: use policies/gates or a package like spatie/laravel-permission.

3) Handling token expiration gracefully

  • Client calls API with token
  • If API returns 401 Unauthorized due to expiration, client calls /auth/refresh
  • If refresh succeeds, retry the original request with the new token
  • If refresh fails (refresh window expired), force re-login

4) Where to store the token (browser apps)

Common options:

  • In-memory (most XSS-resistant; token lost on refresh)
  • localStorage (convenient; higher XSS risk)
  • httpOnly cookies (better XSS protection; needs CSRF strategy; JWT-in-cookie is a different pattern)

For SPAs, many teams prefer in-memory + refresh or httpOnly cookie approaches. If you store in localStorage, invest in XSS prevention.

5) CORS configuration

If your frontend runs on a different origin (e.g., http://localhost:5173), configure CORS in Laravel. In modern Laravel, update config/cors.php to allow your frontend origin and headers (including Authorization).

Simple React.js Example (Login + Authenticated Request + Refresh)

Below is a minimal example using axios. This demonstrates: storing the token, attaching it to requests, and refreshing on 401.

1) Install axios

npm install axios

2) Create an axios client with interceptors

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

const api = axios.create({
  baseURL: "http://localhost:8000/api",
});

let accessToken = null;

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

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

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

    // If token expired, try refresh once
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        const refreshRes = await api.post("/auth/refresh");
        const newToken = refreshRes.data.access_token;
        setAccessToken(newToken);
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return api(originalRequest);
      } catch (refreshErr) {
        // Refresh failed: redirect to login, clear state, etc.
        setAccessToken(null);
        return Promise.reject(refreshErr);
      }
    }

    return Promise.reject(error);
  }
);

export default api;

3) Login and call a protected endpoint

// Login.jsx
import { useState } from "react";
import api, { setAccessToken } from "./api";

export default function Login() {
  const [email, setEmail] = useState("[email protected]");
  const [password, setPassword] = useState("secret");
  const [me, setMe] = useState(null);

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

  async function fetchMe() {
    const res = await api.get("/auth/me");
    setMe(res.data);
  }

  return (
    <div>
      <form onSubmit={handleLogin}>
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
        <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        <button type="submit">Login</button>
      </form>

      <button onClick={fetchMe}>Fetch /me</button>
      {me && <pre>{JSON.stringify(me, null, 2)}</pre>}
    </div>
  );
}

Notes:

  • This example stores the token in memory (a module variable). It resets on page reload.
  • For persistence, you can store it in sessionStorage or use a refresh-token-in-cookie strategy (more advanced).

Simple Vue.js Example (Login + Authenticated Request)

Here’s a minimal Vue 3 example using axios.

1) Install axios

npm install axios

2) Create an axios instance

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

export const api = axios.create({
  baseURL: "http://localhost:8000/api",
});

export function setToken(token) {
  api.defaults.headers.common.Authorization = `Bearer ${token}`;
}

3) Example component

<!-- src/components/JwtDemo.vue -->
<template>
  <div>
    <form @submit.prevent="login">
      <input v-model="email" placeholder="email" />
      <input v-model="password" type="password" placeholder="password" />
      <button>Login</button>
    </form>

    <button @click="me">Fetch /me</button>

    <pre v-if="user">{{ user }}</pre>
    <pre v-if="error">{{ error }}</pre>
  </div>
</template>

<script setup>
import { ref } from "vue";
import { api, setToken } from "../api";

const email = ref("[email protected]");
const password = ref("secret");
const user = ref(null);
const error = ref(null);

async function login() {
  error.value = null;
  const res = await api.post("/auth/login", {
    email: email.value,
    password: password.value,
  });
  setToken(res.data.access_token);
}

async function me() {
  try {
    error.value = null;
    const res = await api.get("/auth/me");
    user.value = JSON.stringify(res.data, null, 2);
  } catch (e) {
    error.value = e?.response?.data || e.message;
  }
}
</script>

To add refresh-on-401 behavior in Vue, you can implement the same axios response interceptor approach shown in the React example.

Troubleshooting & Common Errors

Unauthorized (401) on every request

  • Confirm you’re sending Authorization: Bearer <token>.
  • Ensure config/auth.php API guard uses 'driver' => 'jwt'.
  • Make sure your token wasn’t generated with a different guard than you’re using to authenticate.

Token immediately expires

  • Check server time and timezone settings.
  • Verify JWT_TTL isn’t set to a very small value.
  • Clear config cache: php artisan config:clear.

Refresh fails unexpectedly

  • Verify JWT_REFRESH_TTL is large enough for your use case.
  • If blacklisting is enabled, ensure cache/Redis is configured correctly.
  • Don’t try to refresh an already invalidated token (e.g., after logout).

CORS errors in the browser

  • Allow your frontend origin in config/cors.php.
  • Allow the Authorization header.
  • Ensure preflight (OPTIONS) requests succeed.

Security Best Practices Checklist

  • Always use HTTPS in production.
  • Keep access tokens short-lived (10–30 minutes).
  • Use refresh flow to maintain UX while keeping access TTL short.
  • Enable blacklisting if you need real logout/invalidation behavior.
  • Protect against XSS if storing tokens in web storage.
  • Limit token claims (don’t put secrets in JWT payloads).
  • Rate limit login endpoints and consider account lockout strategies.

Conclusion

JWT authentication in Laravel is straightforward with tymon/jwt-auth: install the package, configure the JWT guard, implement login/me/refresh/logout endpoints, and consume tokens from your SPA. The most important design choice is balancing short access token lifetimes with a secure refresh strategy so your API stays safe without hurting user experience.