15%

Save 15% on All Hosting Services

Test your skills and get Discount on any hosting plan

Use code:

Skills
Get Started
08.10.2024

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`:

ParameterDefaultProduction Recommendation
`ttl`60 minutes15–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 seconds10–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:

  1. On logout, the token's `jti` (JWT ID) claim is stored in the cache with a TTL matching the token's remaining lifetime.
  2. On each authenticated request, the middleware checks the blacklist before accepting the token.
  3. 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.

CriterionJWT (`tymon/jwt-auth`)Laravel SanctumLaravel PassportSession-based
**Statefulness**StatelessStateless (SPA tokens) / Stateful (cookies)Stateful (DB tokens)Stateful
**Token storage**Client-sideClient-side or cookieDatabaseServer-side session
**Revocation**Blacklist (cache)Immediate (DB delete)Immediate (DB delete)Immediate
**Scalability**Excellent (no DB per request)GoodModerate (DB lookup per request)Poor (session sync needed)
**OAuth2 support**NoNoYes (full OAuth2 server)No
**Complexity**MediumLowHighLow
**Best for**Stateless APIs, microservicesSPAs, mobile appsThird-party OAuth clientsTraditional web apps
**Token introspection**Decode payload client-sideRequires API callRequires API callN/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`.

15%

Save 15% on All Hosting Services

Test your skills and get Discount on any hosting plan

Use code:

Skills
Get Started