Building a Secure Laravel API with JWT Authentication
JWT (JSON Web Token) authentication in Laravel provides a stateless, cryptographically signed mechanism for verifying API consumers without server-side session storage. A JWT encodes a payload — typically user identity and claims — into a compact, URL-safe string signed with a secret or RSA key, allowing any service that holds the verification key to validate the token independently.
This guide covers the complete implementation of JWT authentication in a Laravel API using the `tymon/jwt-auth` package, including setup, model configuration, controller logic, route protection, token refresh strategy, and production hardening. Every step includes technical context that goes beyond surface-level tutorials.
Prerequisites and Environment Assumptions
Before starting, confirm the following:
- PHP 8.1 or higher (PHP 8.2 recommended for Laravel 11)
- Laravel 10 or 11 installed via Composer
- Composer 2.x
- MySQL 8.0, PostgreSQL 15, or any PDO-compatible database
- A configured `.env` file with valid `DB_*` credentials
- Basic familiarity with Laravel's service container, middleware, and Eloquent ORM
If you are deploying this API to a production environment, a VPS Hosting plan with full root access gives you the control needed to configure PHP-FPM, manage environment variables securely, and set file permissions correctly — all critical for JWT secret management.
Step 1: Create a New Laravel Project
“`bash
composer create-project laravel/laravel laravel-jwt-api
cd laravel-jwt-api
“`
Verify the installation:
“`bash
php artisan –version
“`
Set your application key if it was not generated automatically:
“`bash
php artisan key:generate
“`
The `APP_KEY` in `.env` is separate from the JWT secret. Both are required and serve different cryptographic purposes — `APP_KEY` protects encrypted cookies and session data, while `JWT_SECRET` signs tokens.
Step 2: Install the JWT Authentication Package
The `tymon/jwt-auth` package is the de facto standard for JWT in Laravel. Install it:
“`bash
composer require tymon/jwt-auth
“`
Publish the package configuration:
“`bash
php artisan vendor:publish –provider="TymonJWTAuthProvidersLaravelServiceProvider"
“`
This creates `config/jwt.php`, which controls token TTL, refresh TTL, algorithm, blacklist behavior, and required claims. Review this file carefully — the defaults are reasonable for development but require tuning for production.
Key configuration parameters in `config/jwt.php`:
| Parameter | Default | Production Recommendation |
|---|
| — | — | — |
|---|
| `ttl` | 60 minutes | 15–30 minutes |
|---|
| `refresh_ttl` | 20160 minutes (2 weeks) | 1440–10080 minutes |
|---|
| `algo` | `HS256` | `RS256` for distributed systems |
|---|
| `blacklist_enabled` | `true` | Must be `true` |
|---|
| `blacklist_grace_period` | 0 seconds | 10–30 seconds for concurrent requests |
|---|
| `required_claims` | `['iss','iat','exp','nbf','sub','jti']` | Keep all; add `aud` for multi-tenant APIs |
|---|
Step 3: Generate the JWT Secret Key
“`bash
php artisan jwt:secret
“`
This appends `JWT_SECRET` to your `.env` file. This value is used as the HMAC-SHA256 signing key for all tokens when using the default `HS256` algorithm.
Critical security notes:
- Never commit `.env` to version control. Add it to `.gitignore` immediately.
- On a shared deployment pipeline, inject `JWT_SECRET` as an environment variable through your CI/CD system rather than storing it in a file.
- If you rotate the secret, all existing tokens are immediately invalidated. Plan rotation windows accordingly and communicate them to API consumers.
- For microservice architectures where multiple services must verify tokens, switch to `RS256`. Generate an RSA key pair, store the private key on the auth service, and distribute only the public key to consuming services.
Step 4: Configure the Authentication Guard
Open `config/auth.php` and update the defaults and guards sections:
“`php
'defaults' => [
'guard' => 'api',
'passwords' => 'users',
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
“`
This instructs Laravel's authentication system to use the JWT driver when resolving the `api` guard. The `auth:api` middleware will now delegate token validation to `tymon/jwt-auth` instead of Laravel Passport or the default token driver.
Do not remove the `web` guard. Many internal Laravel components depend on it, and removing it causes unexpected failures in console commands and queue workers that interact with the authentication system.
Step 5: Create the User Model and Migration
If the default `User` model and migration already exist (they do in a fresh Laravel install), you can modify them directly. If starting from scratch:
“`bash
php artisan make:model User -m
“`
Open the migration file in `database/migrations/` and define the schema:
“`php
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
“`
Run the migration:
“`bash
php artisan migrate
“`
Production note: Always run migrations with `–force` flag in production environments where `APP_ENV=production`, as Laravel will prompt for confirmation otherwise:
“`bash
php artisan migrate –force
“`
Step 6: Implement the JWTSubject Interface in the User Model
The `tymon/jwt-auth` package requires your authenticatable model to implement the `JWTSubject` contract. Open `app/Models/User.php`:
“`php
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateFoundationAuthUser as Authenticatable;
use IlluminateNotificationsNotifiable;
use TymonJWTAuthContractsJWTSubject;
class User extends Authenticatable implements JWTSubject
{
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
/**
- Get the identifier that will be stored in the JWT subject claim.
*/
public function getJWTIdentifier(): mixed
{
return $this->getKey();
}
/**
- Return a key-value array of arbitrary claims to add to the JWT payload.
*/
public function getJWTCustomClaims(): array
{
return [];
}
}
“`
On custom claims: The `getJWTCustomClaims()` method is where you embed application-specific data directly into the token payload. Common use cases include embedding `role`, `tenant_id`, or `permissions` so downstream services can make authorization decisions without a database lookup. Be deliberate about what you embed — every claim increases token size and is readable by anyone who base64-decodes the payload. Never embed sensitive data like passwords or PII.
Step 7: Build the Authentication Controller
“`bash
php artisan make:controller AuthController
“`
Populate `app/Http/Controllers/AuthController.php` with full authentication logic:
“`php
<?php
namespace AppHttpControllers;
use AppModelsUser;
use IlluminateHttpJsonResponse;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesHash;
use IlluminateValidationValidationException;
use TymonJWTAuthExceptionsJWTException;
use TymonJWTAuthFacadesJWTAuth;
class AuthController extends Controller
{
/**
- Register a new user and return a JWT.
*/
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = JWTAuth::fromUser($user);
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
], 201);
}
/**
- Authenticate a user and return a JWT.
*/
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => 'required|string|email',
'password' => 'required|string',
]);
if (!$token = Auth::guard('api')->attempt($credentials)) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
return $this->respondWithToken($token);
}
/**
- Invalidate the current token (logout).
*/
public function logout(): JsonResponse
{
try {
Auth::guard('api')->logout();
} catch (JWTException $e) {
return response()->json(['error' => 'Failed to invalidate token'], 500);
}
return response()->json(['message' => 'Successfully logged out']);
}
/**
- Refresh the current token.
*/
public function refresh(): JsonResponse
{
try {
$token = Auth::guard('api')->refresh();
} catch (JWTException $e) {
return response()->json(['error' => 'Token cannot be refreshed'], 401);
}
return $this->respondWithToken($token);
}
/**
- Return the authenticated user's profile.
*/
public function me(): JsonResponse
{
return response()->json(Auth::guard('api')->user());
}
/**
- Format the token response consistently.
*/
protected function respondWithToken(string $token): JsonResponse
{
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
}
“`
Why explicit guard specification matters: Calling `Auth::attempt()` without specifying the guard falls back to the default guard. If you have changed the default to `api`, this works — but it is fragile. Always call `Auth::guard('api')->attempt()` explicitly to avoid subtle bugs when the default guard changes during refactoring.
Step 8: Define API Routes
Open `routes/api.php` and define the authentication and protected routes:
“`php
<?php
use AppHttpControllersAuthController;
use IlluminateSupportFacadesRoute;
// Public authentication routes
Route::prefix('auth')->group(function () {
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
});
// Protected routes — require a valid JWT
Route::middleware('auth:api')->prefix('auth')->group(function () {
Route::post('logout', [AuthController::class, 'logout']);
Route::post('refresh', [AuthController::class, 'refresh']);
Route::get('me', [AuthController::class, 'me']);
});
// Example protected resource routes
Route::middleware('auth:api')->group(function () {
Route::apiResource('posts', AppHttpControllersPostController::class);
});
“`
Route prefix strategy: Grouping auth endpoints under `/api/auth/` is a widely adopted convention that makes API documentation cleaner and simplifies rate limiting rules — you can apply stricter throttling to `/api/auth/login` independently of resource endpoints.
Step 9: Protecting Routes and Handling Middleware Failures
Laravel's `auth:api` middleware will return a `401 Unauthenticated` response when a token is missing, expired, or invalid. To return a consistent JSON response instead of an HTML redirect, override the `unauthenticated` method in `app/Exceptions/Handler.php`:
“`php
use IlluminateAuthAuthenticationException;
use IlluminateHttpRequest;
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
return redirect()->guest(route('login'));
}
“`
Without this override, API clients receive an HTML 302 redirect to a login page that does not exist in a pure API application — a common source of confusion during integration testing.
Step 10: Token Refresh Strategy and Blacklisting
JWT's stateless nature creates a tension: tokens are self-contained and valid until expiry, but you need a way to revoke them on logout. The `tymon/jwt-auth` package resolves this with a token blacklist backed by the Laravel cache driver.
How the blacklist works:
- On logout, the token's `jti` (JWT ID) claim is stored in the cache with a TTL matching the token's remaining lifetime.
- On each authenticated request, the middleware checks the blacklist before accepting the token.
- The `blacklist_grace_period` setting allows a brief window where a token being refreshed can still be used, preventing race conditions in clients that make concurrent requests.
Ensure your cache driver supports this. The default `file` driver works for single-server deployments. For horizontally scaled APIs running across multiple nodes — common when using Dedicated Servers in a load-balanced configuration — switch to Redis or Memcached:
“`env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
“`
Token refresh flow:
“`
Client API Server
| — POST /api/auth/refresh ——> |
|---|
| Authorization: Bearer <old> |
|---|
| — Validate old token |
|---|
| — Blacklist old token's jti |
|---|
| — Issue new token |
|---|
| <– 200 { token: <new> } ——– |
|---|
“`
The old token is immediately blacklisted upon refresh. Clients must store the new token and discard the old one. Implement this as an Axios interceptor or equivalent in your frontend to handle token refresh transparently.
Step 11: Testing the API
Use `curl` or Postman to verify each endpoint.
Register a user:
“`bash
curl -X POST https://your-domain.com/api/auth/register
-H "Content-Type: application/json"
-d '{
"name": "Jane Smith",
"email": "jane@example.com",
"password": "SecurePass123!",
"password_confirmation": "SecurePass123!"
}'
“`
Expected response (`201 Created`):
“`json
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…",
"token_type": "bearer",
"expires_in": 3600
}
“`
Log in:
“`bash
curl -X POST https://your-domain.com/api/auth/login
-H "Content-Type: application/json"
-d '{"email": "jane@example.com", "password": "SecurePass123!"}'
“`
Access a protected route:
“`bash
curl -X GET https://your-domain.com/api/auth/me
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…"
“`
Refresh the token:
“`bash
curl -X POST https://your-domain.com/api/auth/refresh
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…"
“`
Log out:
“`bash
curl -X POST https://your-domain.com/api/auth/logout
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…"
“`
JWT vs. Session vs. Laravel Sanctum vs. Passport
Understanding when JWT is the right choice requires comparing it against the alternatives available in the Laravel ecosystem.
| Criterion | JWT (`tymon/jwt-auth`) | Laravel Sanctum | Laravel Passport | Session-based |
|---|
| — | — | — | — | — |
|---|
| **Statefulness** | Stateless | Stateless (SPA tokens) / Stateful (cookies) | Stateful (DB tokens) | Stateful |
|---|
| **Token storage** | Client-side | Client-side or cookie | Database | Server-side session |
|---|
| **Revocation** | Blacklist (cache) | Immediate (DB delete) | Immediate (DB delete) | Immediate |
|---|
| **Scalability** | Excellent (no DB per request) | Good | Moderate (DB lookup per request) | Poor (session sync needed) |
|---|
| **OAuth2 support** | No | No | Yes (full OAuth2 server) | No |
|---|
| **Complexity** | Medium | Low | High | Low |
|---|
| **Best for** | Stateless APIs, microservices | SPAs, mobile apps | Third-party OAuth clients | Traditional web apps |
|---|
| **Token introspection** | Decode payload client-side | Requires API call | Requires API call | N/A |
|---|
When to choose JWT over Sanctum: If your API is consumed by third-party clients, mobile apps, or microservices that cannot share a session cookie or database connection with your Laravel app, JWT's self-contained nature is a significant advantage. If you are building a first-party SPA on the same domain, Sanctum with cookie-based authentication is simpler and avoids token storage security concerns entirely.
Production Security Hardening
A working JWT implementation is not a secure one by default. Apply these hardening measures before going live:
1. Enforce HTTPS unconditionally
JWT tokens transmitted over HTTP are trivially interceptable. Enforce TLS at the web server level and redirect all HTTP traffic. Pair this with an SSL Certificate to ensure encrypted transport for every API request.
2. Set aggressive token TTLs
Short-lived access tokens (15–30 minutes) combined with longer-lived refresh tokens (7–14 days) limit the blast radius of a stolen token. Update `config/jwt.php`:
“`php
'ttl' => 15,
'refresh_ttl' => 10080,
“`
3. Apply rate limiting to authentication endpoints
Laravel's built-in throttle middleware prevents brute-force attacks:
“`php
Route::middleware(['throttle:10,1'])->group(function () {
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
});
“`
This limits each IP to 10 requests per minute on auth endpoints.
4. Validate the `aud` claim for multi-tenant APIs
If your API serves multiple client applications, embed and validate an `audience` claim to prevent token reuse across services:
“`php
// In getJWTCustomClaims()
return [
'aud' => config('app.jwt_audience'),
];
“`
5. Protect the JWT_SECRET at the OS level
Set restrictive file permissions on `.env`:
“`bash
chmod 640 .env
chown www-data:www-data .env
“`
On a properly configured VPS with cPanel, you can manage file ownership and permissions through the File Manager or SSH without risk of exposing secrets to other users on the system.
6. Log authentication events
Integrate Laravel's event system to log failed login attempts, token refreshes, and logouts to a centralized logging service. This is essential for anomaly detection.
Adding Role-Based Access Control
Custom claims make it straightforward to embed roles directly in the token:
“`php
// In User model
public function getJWTCustomClaims(): array
{
return [
'role' => $this->role, // e.g., 'admin', 'editor', 'viewer'
];
}
“`
Create a middleware to enforce role requirements:
“`php
<?php
namespace AppHttpMiddleware;
use Closure;
use IlluminateHttpRequest;
use TymonJWTAuthFacadesJWTAuth;
class RoleMiddleware
{
public function handle(Request $request, Closure $next, string $role): mixed
{
$payload = JWTAuth::parseToken()->getPayload();
if ($payload->get('role') !== $role) {
return response()->json(['error' => 'Forbidden'], 403);
}
return $next($request);
}
}
“`
Register it in `app/Http/Kernel.php` (Laravel 10) or `bootstrap/app.php` (Laravel 11) and apply it to routes:
“`php
Route::middleware(['auth:api', 'role:admin'])->group(function () {
Route::apiResource('admin/users', AdminUserController::class);
});
“`
Caveat: Role data embedded in the token is only as fresh as the token itself. If a user's role changes, the old token continues to grant the old role until it expires or is refreshed. For high-security role changes (e.g., revoking admin access immediately), combine token blacklisting with a short TTL or perform a database role check in the middleware alongside the claim check.
Deployment Considerations for Laravel JWT APIs
When moving from local development to a production server, several environment-specific factors affect JWT behavior:
- Timezone consistency: JWT `iat`, `nbf`, and `exp` claims are Unix timestamps. Ensure your server timezone is set to UTC (`date.timezone = UTC` in `php.ini`) to prevent clock-skew token rejection.
- OPcache: Enable PHP OPcache to reduce the overhead of loading JWT library files on every request. This is especially impactful on high-traffic APIs.
- Queue workers for token cleanup: If you implement custom token blacklist cleanup jobs, ensure your queue worker is running as a supervised process (Supervisor or systemd).
- Environment variable management: On VPS Control Panels, use the panel's environment variable manager rather than editing `.env` directly in production, to avoid accidental overwrites during deployments.
Decision Checklist Before Going Live
Use this checklist to verify your implementation is production-ready:
- `JWT_SECRET` is at least 32 characters, randomly generated, and not committed to version control
- `blacklist_enabled` is set to `true` in `config/jwt.php`
- Token TTL is 30 minutes or less for access tokens
- Refresh TTL is set to a value appropriate for your session policy
- All API endpoints are served exclusively over HTTPS
- Rate limiting is applied to `/login` and `/register` endpoints
- The `unauthenticated` exception handler returns JSON, not an HTML redirect
- Custom claims do not contain passwords, secrets, or sensitive PII
- The cache driver is Redis or Memcached (not `file`) in multi-server deployments
- Authentication events are logged and monitored
- Role changes that require immediate effect are handled via blacklisting, not claim expiry alone
- `.env` file permissions are restricted to the web server user
FAQ
What is the difference between `JWT_SECRET` and `APP_KEY` in Laravel?
`APP_KEY` is used by Laravel's encryption service for encrypting cookies, session data, and values passed through `Crypt::encrypt()`. `JWT_SECRET` is used exclusively by `tymon/jwt-auth` to sign and verify JSON Web Tokens. They are cryptographically independent and serve entirely different purposes. Both must be kept secret.
Why does my JWT token keep returning 401 even though it has not expired?
The most common causes are: the token has been blacklisted (e.g., after a logout or refresh), the `JWT_SECRET` was rotated after the token was issued, the cache driver storing the blacklist is unavailable, or there is a clock skew between the issuing server and the validating server exceeding the `leeway` setting in `config/jwt.php`. Check each of these in order.
Can I use JWT authentication with Laravel queues or console commands?
JWT is designed for HTTP request authentication. Inside queue jobs or Artisan commands, there is no HTTP request context, so you cannot resolve a user from a token via middleware. Instead, pass the user's primary key as a job parameter and load the model directly with `User::find($userId)`.
How do I handle concurrent requests during token refresh without getting 401 errors?
Set `blacklist_grace_period` in `config/jwt.php` to a value between 10 and 30 seconds. During this window, a token that has just been refreshed (and technically blacklisted) will still be accepted. This prevents race conditions in clients that fire multiple simultaneous requests while a refresh is in progress.
Is `tymon/jwt-auth` compatible with Laravel 11?
As of the current release cycle, `tymon/jwt-auth` version `2.x` supports Laravel 10 and 11 via the `dev-develop` branch or tagged releases that declare compatibility. Always check the package's `composer.json` constraints and the GitHub issue tracker before upgrading Laravel versions in a project that depends on this package. Consider pinning the package version in `composer.json` to avoid unexpected breaking changes during `composer update`.
