Skip to main content

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
Related chapters

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.

ConceptEnglishPurposeQuestion
AuthenticationAuthenticationVerify the user's identity"Who are you?"
AuthorizationAuthorizationVerify 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 in the presentation layer, authorization in the application and domain 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)

ClaimNameDescription
subSubjectThe token's subject (usually the user ID)
issIssuerThe token's issuer
iatIssued AtThe token's issue time (Unix time)
expExpirationThe token's expiration (Unix time)
nbfNot BeforeInvalid before this time
audAudienceThe 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:

Why use two types of tokens

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

AspectLaravel SanctumJWT (tymon/jwt-auth)
Token formatRandom string (stored in the DB)Self-contained JWT
State managementStateful (DB lookup required)Stateless (signature verification only)
ScalabilityDB load increasesHigh (no DB needed)
Token invalidationPossible immediatelyHard to invalidate before expiration
Suitable caseSmall-to-medium SPAs, first-party APIsLarge-scale systems, microservices
SetupLaravel standardAn additional package is needed
Which should you choose
  • 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.

Supported versions

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

About FormRequest

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

ItemRecommendation
ExpirationKeep the access token short (15 minutes to 1 hour)
Signature algorithmRS256 (asymmetric key) is recommended. With HS256, manage the secret key strictly
PayloadDo not include sensitive information (passwords, personal data)
HTTPSAlways communicate over HTTPS (to prevent token eavesdropping)
Refresh tokenStore 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
Caveats about 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

PointDescription
Authentication vs authorizationAuthentication is "who," authorization is "what you can do"
Layer for authenticationThe presentation layer (middleware)
Layer for authorizationThe application layer (UseCase) and the domain layer
JWT structureHeader.Payload.Signature
Access tokenShort expiration (15 minutes to 1 hour)
Refresh tokenLong expiration, stored in an HttpOnly Cookie
Implementing authorizationCheck user permissions in the UseCase, check state in the domain layer

Reference resources

In the next chapter, we will integrate the knowledge learned so far and actually implement the order system.