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
- Introduction
- Core Concepts
- Architecture Overview
- Step‑by‑Step Guide
- Real‑World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
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:apifor JWT,role:adminfor 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
JwtMiddlewareattaches parsed payload to the request;RoleMiddlewarechecks 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.02. Publish Configuration
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"php artisan jwt:secret3. 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_tableDefine 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=PostImplement 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
| Feature | JWT (tymon/jwt-auth) | Laravel Sanctum | h>Laravel Passport|
|---|---|---|---|
| Stateless | Yes | Yes (API tokens) | Yes (OAuth2) |
| Token Revocation | Blacklist table | Token deletion | Refresh token revocation |
| Role Claims in Token | Custom claims support | Not built‑in | Scopes only |
| Complexity | Low‑Medium | Low | High |
| Best For | SPA, Mobile, Microservices | Simple SPA, First‑party APIs | Third‑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
subclaim (user id) androlesarray to avoid extra DB queries. - Use HTTPS exclusively; enforce
SecureandHttpOnlyflags 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.authmiddleware 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.phpinstead of.env. - Not handling
TokenExpiredExceptiongracefully – results in generic 500 errors. - Over‑granting permissions to the
adminrole; 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
algheader; the package defaults to HS256 – do not acceptnone. - 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:cacheandphp artisan route:cacheafter each deploy. - Ensure the
JWT_SECRETis injected via the platform's secret manager (AWS Parameter Store, Doppler, etc.). - Configure a reverse proxy (Nginx) to terminate TLS and forward the
Authorizationheader 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=truein.envto log token parsing steps (remove in production). - Use
php artisan jwt:secret --showto verify the secret matches across environments. - Inspect the token payload with
jwt.ioduring 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.