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, on the other hand, is the business rule of "whether that operation is permitted," and it is judged in the application and domain layers.
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, each Base64URL-encoded and joined with periods (.).
A JWT joins three parts—Header.Payload.Signature—each Base64URL-encoded, with periods.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIi...
└──────── Header ────────┘.└──────── Payload ────────...
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 |
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) |
| Scalability | DB load increases | High (no DB needed) |
| Token invalidation | Possible immediately | Hard to invalidate before expiration |
| Suitable case | Small-to-medium SPAs, first-party APIs | Large-scale systems, microservices |
| 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
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 Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
];
/**
* The JWT identifier (usually the primary key)
*
* Return int or string to match the primary-key type.
* The JWTSubject interface declares mixed, but narrowing it to
* int|string as a covariant return type improves type safety.
*/
public function getJWTIdentifier(): int|string
{
return $this->getKey();
}
/**
* Custom claims to include in the JWT
* Add things like a role as needed
*
* @return array<string, mixed>
*/
public function getJWTCustomClaims(): array
{
return [
// e.g. 'role' => $this->role,
];
}
}
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 = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$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'),
// Required claims
'required_claims' => [
'iss', // issuer
'iat', // issue time
'exp', // expiration
'nbf', // not-before time
'sub', // subject (user ID)
'jti', // token ID (unique identifier)
],
];
Authorization design in DDD
The division of responsibilities for authorization
Authorization is performed in the application and domain layers.
Implementing authorization in the UseCase
First, define the Command class passed to the UseCase.
// app/Application/UseCase/Order/CancelOrderCommand.php
namespace App\Application\UseCase\Order;
final class CancelOrderCommand
{
public function __construct(
public readonly int $orderId,
public readonly int $requesterId,
public readonly string $requesterRole,
) {}
}
Next, implement the UseCase that includes the authorization check.
// 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;
use App\Domain\Shared\Exception\DomainAuthorizationException;
use App\Domain\User\UserId;
final class CancelOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}
/**
* @throws OrderNotFoundException
* @throws DomainAuthorizationException
*/
public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);
$requesterId = new UserId($command->requesterId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// Authorization check in the application layer
// Only the orderer themselves can cancel
if (!$order->isOwnedBy($requesterId)) {
throw new DomainAuthorizationException(
'You do not have permission to cancel this order'
);
}
// State check in the domain layer (business rule)
// The state is verified inside the cancel() method
$order->cancel();
$this->orderRepository->save($order);
}
}
Expressing authorization in the domain layer
// app/Domain/Order/Order.php
namespace App\Domain\Order;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\User\UserId;
final class Order
{
private function __construct(
private readonly OrderId $id,
private readonly UserId $userId, // the orderer
private OrderStatus $status,
// ...
) {}
/**
* Determine whether this order belongs to the given user
*/
public function isOwnedBy(UserId $userId): bool
{
return $this->userId->equals($userId);
}
/**
* Cancel the order (constrained by domain rules)
*/
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new InvalidOrderStateException(
'An order in this state cannot be cancelled. Current status: ' . $this->status->value
);
}
$this->status = OrderStatus::CANCELLED;
$this->recordEvent(new OrderCancelled($this->id));
}
}
// app/Domain/Order/OrderStatus.php
namespace App\Domain\Order;
enum OrderStatus: string
{
case DRAFT = 'draft';
case CONFIRMED = 'confirmed';
case SHIPPED = 'shipped';
case CANCELLED = 'cancelled';
/**
* Determine whether the state allows cancellation
* Business rule: a shipped order cannot be cancelled
*/
public function canBeCancelled(): bool
{
return match($this) {
self::DRAFT, self::CONFIRMED => true,
self::SHIPPED, self::CANCELLED => false,
};
}
}
Defining the authorization exception
// app/Domain/Shared/Exception/DomainAuthorizationException.php
namespace App\Domain\Shared\Exception;
final class DomainAuthorizationException extends DomainException
{
public function __construct(string $message = 'You do not have permission')
{
parent::__construct(
message: $message,
errorCode: 'UNAUTHORIZED_ACTION',
);
}
}
Handling it in exception handling
In Chapter 16 "Error Handling"'s ->withExceptions() in bootstrap/app.php, add a branch that maps DomainAuthorizationException to 403.
// Inside withExceptions in bootstrap/app.php, added to the render for DomainException
$status = match (true) {
$e instanceof DomainAuthorizationException => Response::HTTP_FORBIDDEN, // 403
$e instanceof OrderNotFoundException => Response::HTTP_NOT_FOUND,
// ...
default => Response::HTTP_BAD_REQUEST,
};
Role-based authorization (optional)
An implementation example for when role-based authorization is needed, such as for administrators.
// app/Domain/User/Role.php
namespace App\Domain\User;
enum Role: string
{
case USER = 'user';
case ADMIN = 'admin';
public function canViewAllOrders(): bool
{
return $this === self::ADMIN;
}
public function canCancelAnyOrder(): bool
{
return $this === self::ADMIN;
}
}
// app/Application/UseCase/Order/CancelOrderUseCase.php (role-aware version)
public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);
$requesterId = new UserId($command->requesterId);
$requesterRole = Role::from($command->requesterRole);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// An admin can cancel any order; a regular user only their own
$canCancel = $requesterRole->canCancelAnyOrder()
|| $order->isOwnedBy($requesterId);
if (!$canCancel) {
throw new DomainAuthorizationException(
'You do not have permission to cancel this order'
);
}
$order->cancel();
$this->orderRepository->save($order);
}
Getting user information in the Controller
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Application\UseCase\Order\CancelOrderCommand;
use App\Application\UseCase\Order\CancelOrderUseCase;
use Illuminate\Http\JsonResponse;
final class OrderController extends Controller
{
public function __construct(
private readonly CancelOrderUseCase $cancelOrderUseCase,
) {}
public function cancel(int $id): JsonResponse
{
// Get the authenticated user from the JWT with auth()->user()
/** @var \App\Models\User $user */
$user = auth()->user();
$command = new CancelOrderCommand(
orderId: $id,
requesterId: $user->id,
requesterRole: $user->role,
);
$this->cancelOrderUseCase->execute($command);
return response()->json([
'message' => 'Order cancelled',
]);
}
}
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 |
| 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 |
Implementing token invalidation (optional)
Because JWT is stateless, immediate invalidation is difficult. If you need it, consider the following methods.
// Blacklist approach (a feature of tymon/jwt-auth)
// config/jwt.php
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
// Add to the blacklist on logout
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 application layer (UseCase) and the domain layer |
| 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 | Check user permissions in the UseCase, check state in the domain layer |
Reference resources
- RFC 7519 - JSON Web Token (JWT) - the official JWT specification
- 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.