To add JWT authentication to a Laravel 13 API, install a maintained JWT package such as php-open-source-saver/jwt-auth, generate a secret with php artisan jwt:secret, make the User model implement JWTSubject, and set the api guard driver to jwt. Login then returns a signed token that clients send as a Bearer header.
This article targets Laravel 13 on PHP 8.3 to 8.5. Before installing, open the package page on Packagist and confirm the latest release lists support for your Laravel version. The code below was written for this article and not executed in a live project.
What is JWT authentication and when should you use it?
A JSON Web Token (JWT) is a signed string that carries claims such as the user id and an expiry time. The server verifies the signature on every request instead of looking up a session. JWT suits stateless APIs and mobile clients. If you only need simple first-party tokens, Laravel Sanctum is easier. If you need a full OAuth2 server, use Passport.
Prerequisites
- PHP 8.3+, Composer, and a Laravel 13 project with a database.
- API routes enabled. In a fresh Laravel 13 app run
php artisan install:apito createroutes/api.php. If it asks about Sanctum, you can answer no only if you do not need it.
Step 1: How do you install the package?
composer require php-open-source-saver/jwt-auth
php artisan vendor:publish --provider="PHPOpenSourceSaver\JWTAuth\Providers\LaravelServiceProvider"
php artisan jwt:secretThe publish step creates config/jwt.php. The jwt:secret command writes JWT_SECRET to your .env. Keep that value private and never commit it.
Step 2: How do you update the User model?
The model must implement JWTSubject, which tells the package what to store in the token.
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use Notifiable;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['password' => 'hashed'];
}
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims(): array
{
return [];
}
}Step 3: How do you configure the auth guard?
In config/auth.php, add an api guard that uses the jwt driver. You can also make it the default guard if the app is API only.
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],Step 4: How do you create register, login and logout endpoints?
Generate the controller: php artisan make:controller Api/AuthController.
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$user = User::create($data);
$token = auth('api')->login($user);
return $this->tokenResponse($token, 201);
}
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! $token = auth('api')->attempt($credentials)) {
return response()->json(['message' => 'Invalid credentials.'], 401);
}
return $this->tokenResponse($token);
}
public function me(): JsonResponse
{
return response()->json(auth('api')->user());
}
public function refresh(): JsonResponse
{
return $this->tokenResponse(auth('api')->refresh());
}
public function logout(): JsonResponse
{
auth('api')->logout();
return response()->json(['message' => 'Logged out.']);
}
private function tokenResponse(string $token, int $status = 200): JsonResponse
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
], $status);
}
}attempt() checks the credentials and returns a token on success. getTTL() returns minutes, so multiplying by 60 gives seconds.
Step 5: How do you protect the routes?
use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:api')->group(function () {
Route::get('/me', [AuthController::class, 'me']);
Route::post('/refresh', [AuthController::class, 'refresh']);
Route::post('/logout', [AuthController::class, 'logout']);
});How do you test it with curl?
curl -X POST http://localhost:8000/api/login \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret-pass"}'
curl http://localhost:8000/api/me \
-H "Accept: application/json" \
-H "Authorization: Bearer YOUR_TOKEN"How long does a token live?
The lifetime comes from JWT_TTL in minutes (the package default is 60). The refresh window is controlled by JWT_REFRESH_TTL. A short TTL with refresh is safer than a long-lived token, because a stolen token expires sooner. With the blacklist enabled, logout invalidates the token before it expires.
Common mistakes
- Missing
jwt:secret. Without a secret, every login throws a signing error. - Guard driver left as
session. Thenauth('api')->attempt()returns a boolean, not a token string. - Storing tokens in localStorage on a web front end. Any XSS bug can steal them; prefer secure, HTTP-only cookies for browser apps.
- Putting secrets in custom claims. JWT payloads are encoded, not encrypted; anyone holding the token can read them.
- No Accept header. Send
Accept: application/jsonso errors come back as JSON.
How do you verify it works?
- Register a user and check for a 201 with an
access_token. - Call
/api/mewith the Bearer token and expect the user JSON. - Call it with a modified token and expect 401.
- Call
/api/logout, then reuse the token and expect 401.
FAQ
Which JWT package should I use?
Use an actively maintained fork such as php-open-source-saver/jwt-auth, and confirm it supports your Laravel version on Packagist.
JWT or Sanctum?
Sanctum for simple first-party apps, JWT when clients need self-contained signed tokens, Passport for OAuth2.
Can I add custom claims?
Yes. Return them from getJWTCustomClaims(), but keep them small and non-sensitive.



