Authentication and Authorization Design — JWT and the Division of Responsibilities Across Layers
What you will learn in this chapter
In this chapter, you will learn about the design of authentication and authorization in DDD and clean architecture.
- The difference between authentication and authorization, and each one's responsibilities per layer
- The mechanism and structure of JWT (JSON Web Token)
- The implementation of Laravel × JWT authentication
- Patterns for authorization design in DDD
- Chapter 02 "The Basics of Laravel API Development": the basics of token authentication with Laravel Sanctum
- Chapter 12 "The Presentation Layer": validation with FormRequest
- Chapter 16 "Error Handling": mapping domain exceptions to HTTP status codes
This chapter explains more detailed authentication design and JWT authentication, which is widely used for SPAs.
The difference between authentication and authorization
Authentication and authorization are often confused, but they are clearly different concepts.
| Concept | English | Purpose | Question |
|---|---|---|---|
| Authentication | Authentication | Verify the user's identity | "Who are you?" |
| Authorization | Authorization | Verify permissions | "Do you have permission to do this?" |
The positioning of authentication and authorization in clean architecture
Authentication and authorization are responsibilities that should be handled in different layers.
Authentication is the process of identifying "who is making the request," and because it requires parsing the HTTP request, it is done in the presentation layer. Authorization is also a decision about "who can access what," and in Laravel the Policy takes that role at the presentation boundary (Chapter 12). What the domain layer holds is the business rule of "whether the operation is permitted by the business."
The basics of JWT (JSON Web Token)
What is JWT
JWT is a token format defined in RFC 7519 for safely transferring JSON-format claims. Signatures allow tampering to be detected, and it enables stateless authentication.
The structure of JWT
A JWT consists of three parts—Header.Payload.Signature—each Base64URL-encoded and joined with periods (.).
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjMiLCJpc3MiOiJteS1hcHAifQ . SflKxwRJSMeKKF2QT4f
─────────── Header ───────────────── . ────────────── Payload ─────────────── . ──── Signature ────
1. Header
{
"alg": "HS256", // signature algorithm (HMAC SHA-256)
"typ": "JWT" // token type
}
2. Payload — contains claims.
{
"sub": "123", // Subject: user ID
"iss": "my-app", // Issuer
"iat": 1699999999, // Issued At
"exp": 1700000899, // Expiration
"role": "admin" // a custom claim
}
3. Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The header and payload are signed with a secret key, making tampering detectable.
Major claims (Registered Claims)
| Claim | Name | Description |
|---|---|---|
sub | Subject | The token's subject (usually the user ID) |
iss | Issuer | The token's issuer |
iat | Issued At | The token's issue time (Unix time) |
exp | Expiration | The token's expiration (Unix time) |
nbf | Not Before | Invalid before this time |
aud | Audience | The token's intended audience |
jti | JWT ID | The token's unique identifier (used to manage invalidation) |
Access tokens and refresh tokens
In JWT-based authentication, it is common to use two types of tokens.
The token refresh flow:
By keeping the access token's expiration short, you can minimize the damage even if the token leaks. However, a short expiration alone degrades the user experience, so you use the refresh token to transparently renew the access token.
Choosing between Sanctum and JWT
| Aspect | Laravel Sanctum | JWT (tymon/jwt-auth) |
|---|---|---|
| Token format | Random string (stored in the DB) | Self-contained JWT |
| State management | Stateful (DB lookup required) | Stateless (signature verification only) |
| Verification cost | One DB lookup per request to resolve the token | Signature verification only, no DB lookup |
| Token invalidation | Possible immediately | Hard to invalidate before expiration |
| Suitable case | An SPA or mobile app on the same domain as Laravel, first-party APIs | A setup that shares tokens across multiple services |
| Setup | Laravel standard | An additional package is needed |
- Sanctum: an SPA on the same domain as Laravel, or when immediate token invalidation is needed
- JWT: sharing authentication across multiple services, or when high scalability is needed
- Passport: when you need full OAuth2 support (authorization code flow, issuing tokens to third-party clients). Laravel's own criterion is whether you need OAuth2, not how large the system is
This book explains JWT authentication, but choose the appropriate method according to your project's requirements.
The implementation of Laravel × JWT authentication
Installing the package
We use tymon/jwt-auth, a JWT authentication library widely used for Laravel.
The code in this chapter assumes Laravel 11 or later and PHP 8.2 or later. tymon/jwt-auth supports Laravel 9 to 13 from v2.3.0 (released 2026-03) onward, and this book uses the stable ^2.3.
# Install the package (stable ^2.3)
composer require tymon/jwt-auth:^2.3
# Publish the config file
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
# Generate the secret key for JWT signing (JWT_SECRET is added to .env)
php artisan jwt:secret
Configuring the authentication guard
// config/auth.php
return [
'defaults' => [
'guard' => 'api', // make the API guard the default
'passwords' => 'users',
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt', // use the JWT driver
'provider' => 'users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
];
Implementing the User model
You need to implement the JWTSubject interface.
// app/Models/User.php
namespace App\Models;
use App\Enums\Role;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use HasFactory;
// role stays out of $fillable. This is the same treatment as Chapter 13 — a column that
// authorization depends on is never filled from a request, closing self-declared privileges
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
];
protected $casts = [
'role' => Role::class,
];
/**
* The JWT identifier (usually the primary key)
*
* The JWTSubject interface declares no return type, so the implementation may add one.
* Narrowing it to int|string to match the primary-key type improves type safety.
*/
public function getJWTIdentifier(): int|string
{
return $this->getKey();
}
/**
* Custom claims to include in the JWT
*
* @return array<string, mixed>
*/
public function getJWTCustomClaims(): array
{
return [
'role' => $this->role->value,
];
}
}
Implementing the authentication controller
The LoginRequest and RegisterRequest used in the code below are validation classes based on the FormRequest pattern explained in Chapter 12 "The Presentation Layer". They are simple implementations that include validation for email and password.
// app/Http/Controllers/AuthController.php
namespace App\Http\Controllers;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
final class AuthController extends Controller
{
/**
* User registration
*/
public function register(RegisterRequest $request): JsonResponse
{
$user = new User();
$user->fill([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// Assign the initial role directly as a fixed value. Omit it and $this->role stays
// null when the JWT claims are built
$user->role = 'user';
$user->save();
$token = auth()->login($user);
return $this->respondWithToken($token, 201);
}
/**
* Login
*/
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->only(['email', 'password']);
if (!$token = auth()->attempt($credentials)) {
return response()->json([
'error' => 'Email or password is incorrect',
'code' => 'INVALID_CREDENTIALS',
], 401);
}
return $this->respondWithToken($token);
}
/**
* Logout (invalidate the token)
*/
public function logout(): JsonResponse
{
auth()->logout();
return response()->json([
'message' => 'Logged out',
]);
}
/**
* Refresh the token
*/
public function refresh(): JsonResponse
{
return $this->respondWithToken(auth()->refresh());
}
/**
* Get the currently authenticated user's information
*/
public function me(): JsonResponse
{
return response()->json(auth()->user());
}
/**
* Build a response that includes the token
*/
private function respondWithToken(string $token, int $status = 200): JsonResponse
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60, // in seconds
], $status);
}
}
Configuring routing
// routes/api.php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\OrderController;
// Endpoints that do not require authentication
Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
});
// Endpoints that require authentication
Route::middleware('auth:api')->group(function () {
// Auth-related
Route::prefix('auth')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/refresh', [AuthController::class, 'refresh']);
Route::get('/me', [AuthController::class, 'me']);
});
// Business logic
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
Route::get('/{id}', [OrderController::class, 'show']);
Route::post('/{id}/confirm', [OrderController::class, 'confirm']);
Route::post('/{id}/cancel', [OrderController::class, 'cancel']);
});
});
Customizing the JWT configuration
// config/jwt.php (excerpt)
return [
// Token expiration (minutes)
// In production, 15 to 60 minutes is recommended
'ttl' => env('JWT_TTL', 60),
// The period during which a refresh is possible (minutes)
// Within this period, an expired token can be refreshed
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), // 2 weeks
// Signature algorithm
// HS256: symmetric key (sign and verify with the same secret key)
// RS256: asymmetric key (sign with the private key, verify with the public key)
'algo' => env('JWT_ALGO', 'HS256'),
// Set these only when you use an asymmetric key (the RS256 / ES256 family)
// With HS256, only 'secret' (JWT_SECRET in .env) is used, so these can stay empty
'keys' => [
'public' => env('JWT_PUBLIC_KEY'),
'private' => env('JWT_PRIVATE_KEY'),
'passphrase' => env('JWT_PASSPHRASE'),
],
// Required claims
'required_claims' => [
'iss', // issuer
'iat', // issue time
'exp', // expiration
'nbf', // not-before time
'sub', // subject (user ID)
'jti', // token ID (unique identifier)
],
];
To switch to RS256, prepare a key pair, point JWT_PUBLIC_KEY / JWT_PRIVATE_KEY at the files, and set JWT_ALGO=RS256. What php artisan jwt:secret generates is the symmetric key for HS256, which RS256 does not use.
Authorization design in DDD
The division of responsibilities for authorization
Authorization judges who can do what (WHO) in the Policy at the presentation boundary, and judges whether the operation is permitted by the business (WHAT) in the domain layer.
Implementing authorization in the UseCase
The CancelOrderCommand passed to the UseCase is already defined in Chapter 11 "Designing the Use Case Layer" (two fields: orderId and reason). Authorization is not part of this Command, because the Controller's Gate::authorize() has already decided who may cancel.
// app/Application/UseCase/Order/CancelOrderUseCase.php
namespace App\Application\UseCase\Order;
use App\Domain\Order\Exception\OrderNotFoundException;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepositoryInterface;
final class CancelOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}
/**
* @throws OrderNotFoundException
*/
public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// The state rule (a shipped order cannot be cancelled) is judged inside cancel()
$order->cancel($command->reason);
$this->orderRepository->save($order);
}
}
What the domain layer holds is only the state rule
The Order in Chapter 6 "Entities" does not hold the orderer. Who owns an order is a concern of authorization, and the Policy decides it by looking at the orders.user_id column through OrderModel (Chapter 12). What the domain layer holds is only "can this state be cancelled."
That decision lives in OrderStatus::canBeCancelled() (Chapter 6), and Order::cancel() calls it and throws when it is violated. The UseCase only calls $order->cancel($command->reason); it does not need to write the state rule itself.
The response when authorization is denied
When the Policy returns false, Gate::authorize() throws and a 403 is returned. In bootstrap/app.php from Chapter 16 "Error Handling", align that exception with the same error / code shape as the other errors.
Put role-based branching in the Policy
A role branch such as "only an admin can operate on every order" is also about who can do what, so it belongs to the Policy. The Role referenced by the User model's $casts is defined as follows.
// app/Enums/Role.php
namespace App\Enums;
enum Role: string
{
case USER = 'user';
case ADMIN = 'admin';
}
The users.role column is added by the migration in Chapter 14 "Domain Model and Table Design". Put the admin bypass in OrderPolicy's before() so that role checks do not spread across its methods. The implementation lives in OrderPolicy in Chapter 12.
Getting user information in the Controller
You can get the authenticated user with auth()->user(). The Controller for the cancel API, however, is consolidated in Chapter 12 "The Presentation Layer". The Controller passes authorization with Gate::authorize('cancel', $order) and then hands the Command built from CancelOrderRequest to the UseCase. That is the implementation behind the /{id}/cancel route registered in the routing section above.
Security considerations
JWT security best practices
| Item | Recommendation |
|---|---|
| Expiration | Keep the access token short (15 minutes to 1 hour) |
| Signature algorithm | RS256 (asymmetric key) is recommended. With HS256, manage the secret key strictly and never use a human-memorizable password as the key (use the random value generated by php artisan jwt:secret) |
| Payload | Do not include sensitive information (passwords, personal data) |
| HTTPS | Always communicate over HTTPS (to prevent token eavesdropping) |
| Refresh token | Store in an HttpOnly Cookie, and hash it when stored in the DB |
| Storage | Keep the access token in memory. localStorage is always readable from JavaScript, so a single XSS leaks every token |
| Pinning the algorithm | Pin the set of algorithms you accept on the server side. Do not use the value of the token's alg header as-is |
| Validating iss / aud | required_claims in config/jwt.php only checks that a claim exists. If you need to narrow the issuer or the audience, implement the value comparison yourself |
The details of claim validation are collected in Section 3 of RFC 8725 - JSON Web Token Best Current Practices.
Token invalidation (the blacklist)
Because JWT is stateless, immediate invalidation is difficult. If you need it, consider the following methods.
The blacklist is enabled by default (config/jwt.php).
// config/jwt.php (excerpt)
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
With this setting, logging out puts that token on the blacklist and it can no longer be used.
auth()->logout(); // internally added to the blacklist
The blacklist is stored in a cache (Redis, etc.). If you prioritize scalability, also consider a design that keeps the access token's expiration short and does not use a blacklist.
Summary
| Point | Description |
|---|---|
| Authentication vs authorization | Authentication is "who," authorization is "what you can do" |
| Layer for authentication | The presentation layer (middleware) |
| Layer for authorization | The presentation layer (Policy) |
| Layer for business rules | The domain layer (Entity / Value Object) |
| JWT structure | Header.Payload.Signature |
| Access token | Short expiration (15 minutes to 1 hour) |
| Refresh token | Long expiration, stored in an HttpOnly Cookie |
| Implementing authorization | Judge owner and role in the Policy, judge state in the domain layer |
Reference resources
- RFC 7519 - JSON Web Token (JWT) - the official JWT specification
- RFC 8725 - JWT Best Current Practices - the required checklist when implementing JWT
- JWT.io - a tool for decoding and verifying JWTs
- tymon/jwt-auth Documentation - the Laravel JWT authentication package
- Auth0 - Refresh Tokens - best practices for refresh tokens
- Laravel official documentation - Authentication
- Laravel official documentation - Authorization
In the next chapter, we will integrate the knowledge learned so far and actually implement the order system.