Back to blog
Laravel
Advanced

Building a Scalable Laravel API with JWT Authentication and Role-Based Access Control

This guide walks you through creating a production‑ready Laravel API secured with JWT tokens and fine‑grained role‑based permissions, covering architecture, implementation, testing, and deployment best practices.

August 27, 202520 min read

Introduction

Building a modern web application often means exposing a robust RESTful API that can serve front‑end frameworks, mobile apps, and third‑party integrations. Laravel makes this straightforward, but securing the API with industry‑standard token‑based authentication and fine‑grained authorization requires a deliberate architecture. In this guide we will build a scalable Laravel API that uses JSON Web Tokens (JWT) for stateless authentication and a role‑based access control (RBAC) layer for authorization.

We assume you have a working Laravel 11 installation, Composer, and a basic understanding of Eloquent models, migrations, and route groups. The tutorial covers package selection, configuration, middleware creation, policy definitions, automated testing, and deployment considerations. By the end you will have a production‑ready codebase you can extend for any SaaS or enterprise product.

Table of Contents

Core Concepts

JSON Web Tokens

JWT is an open standard (RFC 7519) for securely transmitting information between parties as a JSON object. The token consists of three Base64Url‑encoded parts: header, payload, and signature. Because the payload carries claims (user id, roles, expiration), the server can validate the token without a session store, enabling true stateless authentication.

Role‑Based Access Control

RBAC maps permissions to roles rather than individual users. A user receives one or more roles (e.g., admin, editor, viewer). Each role defines a set of permissions (create‑post, delete‑user, view‑dashboard). Laravel Gates and Policies provide a clean API to enforce these rules throughout the application.

tymon/jwt‑auth Package

The community‑maintained tymon/jwt-auth package integrates seamlessly with Laravel's guard system. It supplies a JWT guard, token refresh logic, blacklisting, and custom claims support. We will use version 2.x which targets Laravel 10+ and PHP 8.2+.

Architecture Overview

The API follows a layered architecture:

  • Routes – Versioned under api/v1, grouped by middleware (auth:api for JWT, role:admin for RBAC).
  • Controllers – Thin controllers delegate to Service classes.
  • Services – Contain business logic, interact with Repositories.
  • Repositories – Abstract Eloquent queries, enabling easy testing and swapping.
  • Policies – Authorize actions on models using Gates.
  • Middleware – Custom JwtMiddleware attaches parsed payload to the request; RoleMiddleware checks role claims.

This separation keeps controllers skinny, improves testability, and lets you scale horizontally by adding more service instances behind a load balancer.

Step‑by‑Step Guide

1. Install the Package

composer require tymon/jwt-auth:^2.0

2. Publish Configuration

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

3. Configure Auth Guard

Edit config/auth.php to add an api guard that uses the jwt driver.

'guards' => [    'web' => [        'driver' => 'session',        'provider' => 'users',    ],    'api' => [        'driver' => 'jwt',        'provider' => 'users',        'hash' => false,    ],],

4. Add JWT Claims for Roles

Create a JWTSubject implementation on the User model to embed roles in the token.

use Tymon\JWTAuth\Contracts\JWTSubject;class User extends Authenticatable implements JWTSubject{    // ...    public function getJWTIdentifier()    {        return $this->getKey();    }    public function getJWTCustomClaims()    {        return [            'roles' => $this->roles->pluck('name')->toArray(),        ];    }}

5. Create Role and Permission Migrations

php artisan make:migration create_roles_tablephp artisan make:migration create_permissions_tablephp artisan make:migration create_role_user_tablephp artisan make:migration create_permission_role_table

Define foreign keys and pivot tables. Seed default roles (admin, editor, viewer) and permissions (manage‑users, publish‑post, view‑dashboard).

6. Build Custom Middleware

JwtMiddleware – ensures a valid token is present and attaches the authenticated user to the request.

namespace App\Http\Middleware;use Closure;use Tymon\JWTAuth\Facades\JWTAuth;use Tymon\JWTAuth\Exceptions\TokenExpiredException;use Tymon\JWTAuth\Exceptions\TokenInvalidException;class JwtMiddleware{    public function handle($request, Closure $next)    {        try {            $user = JWTAuth::parseToken()->authenticate();        } catch (TokenExpiredException $e) {            return response()->json(['error' => 'token_expired'], 401);        } catch (TokenInvalidException $e) {            return response()->json(['error' => 'token_invalid'], 401);        } catch (\Exception $e) {            return response()->json(['error' => 'token_absent'], 401);        }        $request->merge(['auth_user' => $user]);        return $next($request);    }}

RoleMiddleware – checks the roles claim against required roles.

namespace App\Http\Middleware;use Closure;class RoleMiddleware{    public function handle($request, Closure $next, ...$roles)    {        $user = $request->get('auth_user');        if (!$user || !array_intersect($roles, $user->getJWTCustomClaims()['roles'] ?? [])) {            return response()->json(['error' => 'forbidden'], 403);        }        return $next($request);    }}

7. Register Middleware

In app/Http/Kernel.php add the middleware to the $routeMiddleware array.

protected $routeMiddleware = [    // ...    'jwt.auth' => \App\Http\Middleware\JwtMiddleware::class,    'role' => \App\Http\Middleware\RoleMiddleware::class,];

8. Define Policies

Generate a policy for the Post model.

php artisan make:policy PostPolicy --model=Post

Implement methods like update, delete that verify the user owns the post or has the manage-posts permission.

public function update(User $user, Post $post){    return $user->id === $post->user_id || $user->can('manage-posts');}

9. Protect Routes

Route::middleware(['jwt.auth'])->group(function () {    Route::apiResource('posts', PostController::class);    Route::middleware('role:admin')->group(function () {        Route::apiResource('users', UserController::class);    });});

10. Write Tests

Use Pest or PHPUnit to test token issuance, refresh, role enforcement, and policy checks. Mock the JWT guard for fast unit tests.

Real‑World Examples

Multi‑Tenant SaaS

In a multi‑tenant system each tenant has its own roles (tenant‑admin, member). The JWT claim can include a tenant_id alongside roles, allowing the RoleMiddleware to enforce tenant‑scoped permissions without extra database lookups.

Mobile App Backend

A React Native app authenticates via /api/auth/login, stores the access token securely, and includes it in the Authorization: Bearer <token> header. Refresh tokens are rotated every 15 minutes using the refresh endpoint provided by the package.

Production Code Examples

AuthController – Login & Refresh

namespace App\Http\Controllers\Api;use App\Http\Controllers\Controller;use Illuminate\Http\Request;use Tymon\JWTAuth\Facades\JWTAuth;use Illuminate\Support\Facades\Auth;use Illuminate\Validation\ValidationException;class AuthController extends Controller{    public function login(Request $request)    {        $credentials = $request->validate([            'email' => 'required|email',            'password' => 'required|string',        ]);        if (!$token = JWTAuth::attempt($credentials)) {            throw ValidationException::withMessages([                'email' => ['The provided credentials are incorrect.'],            ]);        }        return $this->respondWithToken($token);    }    public function refresh()    {        $token = JWTAuth::refresh();        return $this->respondWithToken($token);    }    protected function respondWithToken($token)    {        return response()->json([            'access_token' => $token,            'token_type' => 'bearer',            'expires_in' => JWTAuth::factory()->getTTL() * 60,        ]);    }}

PostPolicy – Authorization

namespace App\Policies;use App\Models\User;use App\Models\Post;use Illuminate\Auth\Access\HandlesAuthorization;class PostPolicy{    use HandlesAuthorization;    public function viewAny(User $user)    {        return true;    }    public function view(User $user, Post $post)    {        return $post->is_published || $user->id === $post->user_id || $user->can('manage-posts');    }    public function create(User $user)    {        return $user->can('create-posts');    }    public function update(User $user, Post $post)    {        return $user->id === $post->user_id || $user->can('manage-posts');    }    public function delete(User $user, Post $post)    {        return $user->id === $post->user_id || $user->can('manage-posts');    }}

Service Class – Business Logic

namespace App\Services;use App\Models\Post;use App\Repositories\PostRepository;use Illuminate\Pagination\LengthAwarePaginator;class PostService{    protected $repository;    public function __construct(PostRepository $repository)    {        $this->repository = $repository;    }    public function getAll(array $filters = []): LengthAwarePaginator    {        return $this->repository->paginate($filters);    }    public function create(array $data): Post    {        return $this->repository->create($data);    }    public function update(Post $post, array $data): Post    {        return $this->repository->update($post, $data);    }    public function delete(Post $post): bool    {        return $this->repository->delete($post);    }}

Comparison Table

h>Laravel Passport
FeatureJWT (tymon/jwt-auth)Laravel Sanctum
StatelessYesYes (API tokens)Yes (OAuth2)
Token RevocationBlacklist tableToken deletionRefresh token revocation
Role Claims in TokenCustom claims supportNot built‑inScopes only
ComplexityLow‑MediumLowHigh
Best ForSPA, Mobile, MicroservicesSimple SPA, First‑party APIsThird‑party OAuth2 providers

Best Practices

  • Store the JWT secret in the environment file, never commit it.
  • Set a short TTL (15‑30 minutes) and use refresh tokens for long sessions.
  • Rotate refresh tokens on each use and invalidate the previous one.
  • Add the sub claim (user id) and roles array to avoid extra DB queries.
  • Use HTTPS exclusively; enforce Secure and HttpOnly flags on cookies if you store tokens there.
  • Implement rate limiting on authentication endpoints to mitigate brute‑force attacks.
  • Log failed authentication attempts with IP and user agent for audit trails.
  • Run automated security scans (e.g., OWASP ZAP) in CI pipelines.

Common Mistakes

  • Using a long‑lived access token without refresh rotation – increases breach impact.
  • Forgetting to add the jwt.auth middleware on protected routes – leads to open endpoints.
  • Storing roles only in the database and not in the token – forces a DB hit on every request.
  • Hard‑coding the secret in config/jwt.php instead of .env.
  • Not handling TokenExpiredException gracefully – results in generic 500 errors.
  • Over‑granting permissions to the admin role; follow principle of least privilege.
  • Skipping automated tests for policy logic – regressions appear in production.
  • Ignoring CORS configuration; allow only trusted origins.

Performance Tips

  • Cache the user's role collection in Redis for 5 minutes; the JWT claim still provides immediate authorization.
  • Use eager loading (with('roles.permissions')) when seeding the token to avoid N+1 queries.
  • Enable OPcache and JIT in PHP 8.2+ for faster token parsing.
  • Offload token blacklist cleanup to a scheduled command that prunes expired entries daily.
  • Leverage Laravel Octane (Swoole/RoadRunner) to keep the application in memory, reducing bootstrapping overhead per request.

Security Considerations

  • Always validate the alg header; the package defaults to HS256 – do not accept none.
  • Implement Content Security Policy (CSP) headers for any front‑end that consumes the API.
  • Use the aud (audience) claim to restrict tokens to specific clients.
  • Rotate the JWT signing key periodically; provide a grace period where both old and new keys are accepted.
  • Apply the principle of least privilege to API keys used for server‑to‑server communication.
  • Monitor for anomalous token usage patterns (e.g., same token from multiple geolocations).

Deployment Notes

  • Run php artisan config:cache and php artisan route:cache after each deploy.
  • Ensure the JWT_SECRET is injected via the platform's secret manager (AWS Parameter Store, Doppler, etc.).
  • Configure a reverse proxy (Nginx) to terminate TLS and forward the Authorization header unchanged.
  • Set up health‑check endpoints (/api/health) that do not require authentication for load balancer probes.
  • Use zero‑downtime deployments (e.g., Laravel Envoy or Deployer) with a shared storage for the JWT blacklist table.

Debugging Tips

  • Enable JWT_DEBUG=true in .env to log token parsing steps (remove in production).
  • Use php artisan jwt:secret --show to verify the secret matches across environments.
  • Inspect the token payload with jwt.io during development – never paste production tokens.
  • Laravel Telescope can capture authentication events; filter by auth.failed.
  • Write a custom Artisan command to generate a test token for a given user and role set.

FAQ

Can I use JWT with Laravel Sanctum simultaneously?

Yes. Sanctum handles first‑party SPA authentication via cookies, while JWT serves stateless mobile or third‑party clients. Register both guards and apply the appropriate middleware per route group.

How do I revoke a token before its expiration?

Add the token's jti claim to the jwt_blacklist table using the JWTAuth::setToken($token)->invalidate() method. A scheduled job should prune expired entries.

What is the recommended TTL for access tokens?

15‑30 minutes is a common balance. Pair it with a refresh token that lives 7‑30 days and rotates on each use.

Can I store custom data like tenant ID in the token?

Absolutely. Override getJWTCustomClaims() on the User model to return any serializable data you need for authorization.

Does the package support RS256 asymmetric keys?

Yes. Set JWT_ALGO=RS256 and provide JWT_PRIVATE_KEY / JWT_PUBLIC_KEY in the environment. This enables key rotation without sharing the secret.

How do I test policies without a full HTTP request?

Use Gate::forUser($user)->allows('update', $post) in your unit tests. Mock the user's roles/permissions as needed.

Is it safe to put the JWT in localStorage on the client?

LocalStorage is vulnerable to XSS. For high‑security apps prefer HttpOnly Secure cookies with SameSite=Strict, or store the token in memory and refresh via a silent iframe.

What happens if the secret key is compromised?

Immediately rotate the secret, invalidate all existing tokens (truncate the blacklist table), and force users to re‑authenticate.

Conclusion

You now have a complete blueprint for a scalable Laravel API secured with JWT authentication and role‑based access control. By keeping authentication stateless, embedding authorization data in the token, and leveraging Laravel's policies, you achieve both performance and maintainability. Apply the best practices, avoid the common pitfalls, and continuously test your security posture.

Ready to level up your Laravel skills? Subscribe to the newsletter for more deep‑dive tutorials, or explore the related guides on Sanctum, Octane, and production deployment linked above.