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
Usermodel (default Laravel auth scaffolding is fine) - API routes (typically in
routes/api.php)
Step 1: Install the package
composer require tymon/jwt-authStep 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:secretThis 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=20160Typical 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:
- Validates the credentials against your user provider (usually the
userstable). - 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_TTLto something small (10–30 minutes is common). - Refresh window: Set
JWT_REFRESH_TTLto 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=trueBlacklisting requires a cache store (Redis is common). Configure your cache properly in .env:
CACHE_STORE=redisWith 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 Unauthorizeddue 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 axios2) 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
sessionStorageor 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 axios2) 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.phpAPI 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_TTLisn’t set to a very small value. - Clear config cache:
php artisan config:clear.
Refresh fails unexpectedly
- Verify
JWT_REFRESH_TTLis 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
Authorizationheader. - 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.