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 and authorization in the presentation layer, business rules in the domain layer

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)

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
jtiJWT IDThe 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:

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)
Verification costOne DB lookup per request to resolve the tokenSignature verification only, no DB lookup
Token invalidationPossible immediatelyHard to invalidate before expiration
Suitable caseAn SPA or mobile app on the same domain as Laravel, first-party APIsA setup that shares tokens across multiple services
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
  • 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.

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

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 = 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

ItemRecommendation
ExpirationKeep the access token short (15 minutes to 1 hour)
Signature algorithmRS256 (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)
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
StorageKeep the access token in memory. localStorage is always readable from JavaScript, so a single XSS leaks every token
Pinning the algorithmPin 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 / audrequired_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
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 presentation layer (Policy)
Layer for business rulesThe domain layer (Entity / Value Object)
JWT structureHeader.Payload.Signature
Access tokenShort expiration (15 minutes to 1 hour)
Refresh tokenLong expiration, stored in an HttpOnly Cookie
Implementing authorizationJudge owner and role in the Policy, judge 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.