Skip to content

8. Authentication and Authorization

Two distinct questions arise for each request:

  • Who is the user? This is authentication: the user has identified themselves with a username and password, and every subsequent request must prove that it comes from them;
  • Do they have permission to access this page? This is authorization: in the case study, only a user with the role ADMIN manages doctors and clients.

Proof of identity must accompany every request. [Laravel] typically stores the logged-in user’s ID in their session (see the chapter on scopes), which is the simplest solution for a web application. The case study, in all its versions ([ASP.NET Core MVC], [Spring MVC], [NestJS], [Flask]), instead uses a token (JWT (*JSON Web Token*)), which we will use here: after login, the server generates a token containing the user’s identity and signs it with a secret key. The token is stored in a cookie; the browser sends it back with every request; the server verifies the signature. The user can read their token but cannot modify it: any modification invalidates the signature. The format is standardized (RFC 7519), understood by all programming languages, and the token includes its expiration date. Most importantly, the server doesn’t need to store anything: any server that knows the key can verify the token.

[Laravel] has a complete authentication system, the components of which we will use as-is:

  • authentication is handled by a *guard*: an object that, for each request, identifies the authenticated user. [Laravel] provides a guard that reads the session; we’ll write our own, which reads the token, in just a few lines. The rest of the application sees only the guard: [auth()->user()], [#[CurrentUser]], and [@auth] in the views;
  • page protection is handled by route middleware (previous chapter): [auth] requires an authenticated user, [can:admin] requires authorization;
  • authorizations are described by Gates (e.g., 26);
  • Passwords are hashed by the [Hash] facade (using the *bcrypt* algorithm), and attempt limits are enforced by the [RateLimiter] facade (Example 27).

Token generation and verification are handled by the [firebase/php-jwt] library, the most widely used in [PHP], installed by the [composer.json] file from the examples.

8.1. Example 25: Login, Token, Protected Page

25-jwt/
├── app/
│   ├── Auth/
│   │   ├── AuthService.php
│   │   ├── User.php
│   │   └── UsersService.php
│   └── Http/
│       └── Controllers/
│           ├── AuthController.php
│           └── ProfileController.php
├── bootstrap/
│   └── app.php
├── config/
│   ├── auth.php
│   └── jwt.php
├── resources/
│   └── views/
│       ├── auth/
│       │   └── login.blade.php
│       ├── layout.blade.php
│       └── profile/
│           └── profile.blade.php
├── routes/
│   └── web.php
└── ...

8.1.1. Configuration: .env, config/jwt.php, and config/auth.php

The token is signed with a secret key, which must not be hard-coded. It’s time to truly separate the configuration from the code. The settings specific to an installation are in the file [.env] (Example 01), which now includes two new lines:

1
2
3
# the key that signs the JWT tokens (at least 32 characters), and their lifetime
JWT_SECRET=a-secret-key-of-at-least-32-characters-for-example-25
JWT_DURATION_MINUTES=60

The standard requires, for the algorithm used (HMAC-SHA256), a key of at least 256 bits, or 32 bytes; the library rejects a shorter key. The code, however, does not read the [.env] file: it reads the application configuration—a set of [PHP] files stored in the [config] folder—each of which returns an array. We create the [config/jwt.php] file:

<?php
// config/jwt.php: the configuration of the JWT tokens, read by config('jwt.secret')...
// env(NAME, default): the NAME variable of the environment or of the .env file
return [
    // the key that signs the tokens (at least 32 characters): never in the code
    'secret' => env('JWT_SECRET'),
    // the lifetime of a token, in minutes
    'duration' => (int) env('JWT_DURATION_MINUTES', 60),
    // the name of the cookie that carries the token
    'cookie' => 'token',
];
  • Line 6: The [env] function reads a variable from the process environment or, failing that, from the [.env] file; its second parameter is a default value. An environment variable takes precedence over the file: in production, the secret key is provided by the environment, and the file [.env] does not contain one;
  • line 8: Environment variables are strings: [(int)] converts the duration to an integer.

The code reads these values using the [config] function: [config('jwt.secret')] refers to the [secret] key in the [jwt.php] file. [env] is only called in configuration files: in production, the command [php artisan config:cache] combines the entire configuration into a single file to speed up the process, and [.env] is no longer read. The [config/auth.php] file configures authentication:

<?php
// config/auth.php: the authentication. This file COMPLETES Laravel's default
// configuration (vendor/laravel/framework/config/auth.php)
return [
    // the "guard" used by default: the one that reads the JWT token
    'defaults' => [
        'guard' => 'jwt',
    ],
    'guards' => [
        // a guard with the [jwt] driver, defined in bootstrap/app.php (Auth::viaRequest)
        'jwt' => [
            'driver' => 'jwt',
        ],
    ],
];

[Laravel] has its own authentication configuration (the [vendor/laravel/framework/config/auth.php] file; see Example 01); the application’s configuration supplements it: the keys it provides replace those of the framework, while the others retain their values. We declare a guard named [jwt], with driver [jwt] (line 12), and set it as the default guard (line 7). The driver [jwt] does not exist in [Laravel]: we define it in [bootstrap/app.php].

Under [Linux] or [macOS], we would launch the application with a different key as follows:

JWT_SECRET="another-key-of-at-least-thirty-two-characters" php artisan serve

and under [Windows] (PowerShell):

$env:JWT_SECRET="another-key-of-at-least-thirty-two-characters"
php artisan serve

8.1.2. Users: app/Auth/User.php and UsersService.php

<?php
// app/Auth/User.php: a user. Authenticatable: the interface that
// Laravel expects from authenticated users

namespace App\Auth;

use Illuminate\Contracts\Auth\Authenticatable;

readonly class User implements Authenticatable
{
    public function __construct(
        public string $login,
        public string $name,
        public string $role,
        // the HASH of the password (empty for a user read from a token)
        public string $passwordHash = '',
        // the content of the token that authenticated the user
        public array $claims = [],
    ) {
    }

    // the identifier of the user: their login
    public function getAuthIdentifierName(): string
    {
        return 'login';
    }

    public function getAuthIdentifier(): string
    {
        return $this->login;
    }

    public function getAuthPasswordName(): string
    {
        return 'passwordHash';
    }

    public function getAuthPassword(): string
    {
        return $this->passwordHash;
    }

    // the "remember me" token of the sessions: useless here
    public function getRememberToken(): ?string
    {
        return null;
    }

    public function setRememberToken($value): void
    {
    }

    public function getRememberTokenName(): string
    {
        return '';
    }
}
  • Line 9: a user. [Laravel] expects authenticated users to implement the [Authenticatable] interface: its methods provide it with the user’s ID, their hashed password, and the “remember me” session token, which is unnecessary here. In a typical application, the [Eloquent] model for the user table (next chapter) implements this;
  • lines 12–18: the login, name, role, password hash, and the contents of the token that authenticated the user (empty for a user retrieved from the user list). A server never stores a password, even if it’s encrypted: if the database were stolen, the passwords would be too (and many people use the same password everywhere). It stores the result of a hash function, a one-way function: you can calculate the hash of a password, but you cannot recover a password from its hash. To verify an entered password, you calculate its hash and compare it to the one on file.
<?php
// app/Auth/UsersService.php: the users, in memory (example 28 and
// the case study will put them in a database)

namespace App\Auth;

class UsersService
{
    // we NEVER keep a password, only its bcrypt hash, computed
    // once and for all by: php -r "echo password_hash('marie', PASSWORD_BCRYPT);"
    private const USERS = [
        'marie' => ['Marie Pélissier', 'USER', '$2y$10$2DnOgGBiVcyc07su9rj2E.EvOoyPFzHN/h9PLKDuoW5P7UGtclW6W'],
        'admin' => ['Administrator', 'ADMIN', '$2y$10$m5o9euCi0EdgoZkLVw.UsO58RxJcqhMBfVrlLMCIOh2LyGSjuN29K'],
    ];

    public function find(string $login): ?User
    {
        if (! isset(self::USERS[$login])) {
            return null;
        }
        [$name, $role, $hash] = self::USERS[$login];
        return new User($login, $name, $role, $hash);
    }
}
  • Lines 11–14: Two test users, hard-coded into the program (Example 28 and the case study will populate a database with them): for each login, the name, role, and password hash. The hash is generated using the bcrypt algorithm, which is designed for passwords. It has two distinctive features. It is intentionally slow, with an adjustable level of slowness (the cost, here set to 10: 2^10 computational cycles, resulting in the hash [$10$]): a few tens of milliseconds, imperceptible to a user, but which make it very costly for a hacker to try billions of passwords. It is salted: a randomly generated salt is mixed with the password before hashing and stored within the hash itself (the 22 characters following [$10$]); thus, two users with the same password will have different hashes. The hashes were calculated once and for all by the function [password_hash] from [PHP] (line 10): calculating them with every request would be an unnecessary waste of time, since the application is rebuilt with every request. [bcrypt] considers only the first 72 bytes of a password: the case study takes this into account;
  • Lines 16–23: the user of a login, or [null]. [isset] checks for the existence of a key; the assignment [[$name, $role, $hash] = ...] destructures an array: each variable receives the corresponding element.

8.1.3. The authentication service: app/Auth/AuthService.php

<?php
// app/Auth/AuthService.php: the checking of the passwords and the JWT tokens

namespace App\Auth;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Hash;
use Throwable;

class AuthService
{
    public function __construct(private UsersService $users)
    {
    }

    // the user, or null if the login is unknown or the password wrong.
    // Hash::check hashes the entered password with the salt of the stored hash,
    // then compares the two results
    public function validate(?string $login, ?string $password): ?User
    {
        if (! $login || $password === null) {
            return null;
        }
        $user = $this->users->find($login);
        if ($user === null || ! Hash::check($password, $user->passwordHash)) {
            return null;
        }
        return $user;
    }

    // the JWT token of the user, signed with the secret key (HMAC-SHA256)
    public function createToken(User $user): string
    {
        $now = time();
        // the content of the token: the "claims" (statements about the user)
        $claims = [
            'sub' => $user->login,
            'name' => $user->name,
            'role' => $user->role,
            'iat' => $now,
            'exp' => $now + config('jwt.duration') * 60,
        ];
        return JWT::encode($claims, config('jwt.secret'), 'HS256');
    }

    // the user described by a token whose signature and expiration date
    // are valid; null otherwise (forged, expired or unreadable token)
    public function check(string $token): ?User
    {
        try {
            // decode returns an object; (array) converts it into an associative array
            $claims = (array) JWT::decode($token, new Key(config('jwt.secret'), 'HS256'));
        } catch (Throwable) {
            return null;
        }
        return new User($claims['sub'], $claims['name'], $claims['role'], claims: $claims);
    }
}
  • lines 20–30: verification of a username and password. [Hash::check] hashes the entered password using the stored hash salt and compares the two hashes. An unknown username and an incorrect password yield the same result, [null];
  • lines 33–45: generating the token. Its content is an array of claims about the user. Some have standardized names: [sub] (*subject*), the user’s identifier; [iat] (*issued at*), the creation date; [exp] (*expiration*), the expiration date. Dates are expressed in seconds elapsed since January 1, 1970 (the function [time()] of [PHP]). The other fields are optional: here, the name and role. [JWT::encode] generates the token and signs it using the [HS256] algorithm (HMAC-SHA256, a signature using a shared secret key);
  • Lines 49–58: token verification. [JWT::decode] verifies the signature and expiration date, then returns the assertions (an object, converted to an array by [(array)]). Any anomaly (forged, expired, or malformed token) raises an exception; [catch (Throwable)] catches them all ([Throwable] is the interface for all exceptions and errors from [PHP]; the variable name is optional when not used). The [Key] object associates the key with an algorithm: a token that specifies a different algorithm (or none at all, [none]) would be rejected. The valid token becomes a [User], without a hash, along with its assertions (parameter named [claims]).

8.1.4. The guard: bootstrap/app.php

<?php
// bootstrap/app.php: the application of example 25

use App\Auth\AuthService;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;

(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // the route that serves Bootstrap (see example 15)
        then: fn () => require __DIR__.'/../../common/bootstrap.php')
    ->withMiddleware(function (Middleware $middleware): void {
        // the token is already signed: the cookie that carries it is not encrypted
        $middleware->encryptCookies(except: ['token']);
        // an anonymous visitor who requests a protected page: off to /login
        $middleware->redirectGuestsTo('/login');
    })
    ->withExceptions()
    ->booted(function () {
        // the [jwt] guard driver (see config/auth.php): a function that receives the
        // request, and returns the authenticated user, or null for an anonymous visitor
        Auth::viaRequest('jwt', function (Request $request) {
            $token = $request->cookie(config('jwt.cookie'));
            if ($token === null) {
                return null;
            }
            $user = app(AuthService::class)->check($token);
            if ($user === null) {
                // a cookie present but invalid (expired, forged token): we delete it
                Cookie::queue(Cookie::forget(config('jwt.cookie')));
            }
            return $user;
        });
    })
    ->create();
  • line 20: the token is already signed; the cookie carrying it is not encrypted (example 22), which allows it to be examined;
  • line 22: the [auth] middleware will redirect anonymous visitors to the login page;
  • lines 28–39: [Auth::viaRequest] defines a guard driver using a simple function: it receives the request and returns the authenticated user, or [null] for an anonymous visitor. It is called only if the application needs the user, and, once it has found one, [Laravel] retains it for the entire request (for an anonymous visitor, it is called with every request) (the middleware [auth], [auth()->user()]...). It does not reject anything: it determines who the user is. This is authentication. [app(AuthService::class)] requests an object from the container (see Example 06): the function [app] grants access to the container everywhere;
  • Lines 34–39: If the browser sent a cookie but its token is invalid (expired or tampered with), we take this opportunity to delete it. [Cookie::forget] creates an empty cookie that has already expired; [Cookie::queue] queues it: the [AddQueuedCookiesToResponse] middleware in the [web] group will add it to the response, whatever that response may be.

8.1.5. The controllers

<?php
// app/Http/Controllers/AuthController.php: login and logout

namespace App\Http\Controllers;

use App\Auth\AuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class AuthController
{
    public function __construct(private AuthService $auth)
    {
    }

    public function home(): RedirectResponse
    {
        return redirect('/profile');
    }

    public function form(): View
    {
        return view('auth.login');
    }

    public function login(Request $request): View|RedirectResponse
    {
        $login = $request->input('login');
        $user = $this->auth->validate($login, $request->input('password'));
        if ($user === null) {
            // a deliberately vague message: we do not say whether it is the login or the password
            return view('auth.login', ['login' => $login,
                'error' => 'Incorrect login or password.']);
        }
        // the token, in a cookie: the JavaScript of the page cannot read it
        // (httpOnly); it does not go with the POST requests coming from other sites
        // (sameSite); it disappears at the same time as the token expires (duration in minutes)
        return redirect('/profile')->withCookie(cookie(config('jwt.cookie'),
            $this->auth->createToken($user), config('jwt.duration'),
            httpOnly: true, sameSite: 'lax'));
    }

    // logging out: POST only (a simple link, placed on another site, cannot
    // log the user out without their knowledge); we delete the cookie
    public function logout(): RedirectResponse
    {
        return redirect('/login')->withoutCookie(config('jwt.cookie'));
    }
}
  • lines 30–34: the login. If the login fails, the form is redisplayed with the entered username and a deliberately vague message: saying “unknown username” would reveal to a hacker which usernames exist;
  • lines 39–41: if successful, the token is stored in a cookie, and the user is redirected to the protected page (schema PRG). The cookie is [httpOnly]: the page’s [JavaScript] cannot read it, which protects the token against script injection (XSS); it is [sameSite: 'lax']: the browser does not send it with a POST request originating from another site, which protects the application against CSRF attacks (example 18); its lifetime (in minutes) is the same as the token’s. In production, for HTTPS, we would add [secure: true]: the cookie would never be sent over an unencrypted connection;
  • Lines 46–49: Logging out deletes the cookie ([withoutCookie]). This is a POST action: a simple link to [/logout], placed on another site or within an image, cannot log the user out without their knowledge. Note a limitation of tokens: the token itself remains valid until it expires. Someone who has made a copy of it could still use it. That is why their lifetime is limited.
<?php
// app/Http/Controllers/ProfileController.php: a page reserved for authenticated users

namespace App\Http\Controllers;

use App\Auth\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\View\View;

class ProfileController
{
    // #[CurrentUser]: the container provides the authenticated user of the request,
    // the one found by the guard (see bootstrap/app.php)
    public function display(#[CurrentUser] User $user): View
    {
        return view('profile.profile', ['user' => $user]);
    }
}
  • Line 14: The attribute [#[CurrentUser]] requests the authenticated user from the container—the one found by the guard (see Example 12 for parameter attributes). The action is protected by the [auth] middleware (see the routing table): it is guaranteed to receive a user. It could also be obtained via [auth()->user()] or [$request->user()].

The routing table:

<?php
// routes/web.php: the routes of example 25

use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AuthController::class, 'home']);
Route::get('login', [AuthController::class, 'form']);
Route::post('login', [AuthController::class, 'login']);
Route::post('logout', [AuthController::class, 'logout']);

// the [auth] middleware: the route is accessible only to an authenticated user
Route::get('profile', [ProfileController::class, 'display'])->middleware('auth');
  • Line 14: The [auth] middleware protects the profile page. For an anonymous visitor, it throws a [AuthenticationException] exception, which [Laravel] converts into a redirect to the login page ([redirectGuestsTo]). [auth] is an alias—a short name that [Laravel] associates with the middleware [Illuminate\Auth\Middleware\Authenticate]. A group of routes can be protected all at once: [Route::middleware('auth')->group(...)].

8.1.6. The views

@extends('layout')

@section('content')
  <h1 class="h3">Login</h1>
  <form method="post" action="/login">
    @csrf
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ $login ?? '' }}" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="password">Password</label>
      <input class="form-control" type="password" id="password" name="password" required>
    </div>
    <button class="btn btn-primary" type="submit">Log in</button>
  </form>
  @isset($error)
    <div class="alert alert-danger mt-3">{{ $error }}</div>
  @endisset
  <p class="text-muted small mt-3">Test accounts (password = login): marie (USER), admin (ADMIN).</p>
@endsection
  • Line 6: The login form is protected against forged requests, just like all forms (Example 18);
  • line 9: the entered login is redisplayed after a failed attempt.
@extends('layout')

@section('content')
  <h1 class="h3">Hello {{ $user->name }}</h1>
  <p>Your role: <b>{{ $user->role === 'ADMIN' ? 'administrator' : 'user' }}</b>.</p>
  <p>The content of your token:</p>
  <table class="table table-sm w-auto">
    @foreach ($user->claims as $key => $value)
      <tr><td><code>{{ $key }}</code></td><td>{{ $value }}</td></tr>
    @endforeach
  </table>
  {{-- logging out: a POST form reduced to its button --}}
  <form method="post" action="/logout">
    @csrf
    <button class="btn btn-outline-secondary" type="submit">Log out</button>
  </form>
@endsection
  • Lines 8–10: The token’s contents, displayed for illustrative purposes;
  • lines 13–16: Logout, via a form reduced to a single button.

8.1.7. Execution

Let’s request the protected page [/profile] without being logged in:

curl -i http://localhost:8000/profile
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/login
Content-Type: text/html; charset=utf-8
...

An incorrect password:

Image

Login successful. With [curl], you must first request the form to obtain the anti-CSRF token and the session cookie (example 18), then submit the form while retaining the received cookie:

curl -c token.txt -b token.txt http://localhost:8000/login
curl -i -c token.txt -b token.txt -d "_token=...&login=marie&password=marie" http://localhost:8000/login
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/profile
Set-Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtYXJpZSIsIm5hbWUiOiJNYXJpZSBQXHUwMGU5bGlzc2llciIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzkwNDM5NjgyLCJleHAiOjE3OTA0NDMyODJ9.cvP1bi4zFamN79t2nqG7WXYLIF3kewt6UTOVb3swNtw; expires=Sat, 26 Sep 2026 17:21:22 GMT; Max-Age=3600; path=/; httponly; samesite=lax
...

The token consists of three parts, separated by periods, each encoded in Base64: the header, the content, and the signature. The first two parts can be decoded without any key:

{"typ":"JWT","alg":"HS256"}
{"sub":"marie","name":"Marie Pélissier","role":"USER","iat":1790439682,"exp":1790443282}

The expiration time is one hour (3,600 seconds) after creation. In the browser, the profile page displays this same content:

Image

A malicious user modifies their token: they replace the role [USER]*** in the content with [ADMIN]***, re-encode this part in Base64, and keep the original signature. The server recalculates the signature of the modified content, which no longer matches:

curl -i -b "token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.<modified content>.cvP1bi4zFamN79t2nqG7WXYLIF3kewt6UTOVb3swNtw" http://localhost:8000/profile
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/login
Set-Cookie: token=deleted; expires=Fri, 26 Sep 2025 16:21:27 GMT; Max-Age=0; path=/; httponly; samesite=lax
...

The forged token is rejected and deleted (the value [deleted] and the expired date instruct the browser to delete the cookie). To generate a valid signature, one would need to know the secret key—hence the importance of keeping it secret and choosing a long, random key.

8.2. Example 26: Roles

Example 26 adds an administration page, restricted to the role ADMIN. Only new or modified files are displayed.

8.2.1. An authorization: bootstrap/app.php

<?php
// bootstrap/app.php: the application of example 26

use App\Auth\AuthService;
use App\Auth\User;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Gate;

(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // the route that serves Bootstrap (see example 15)
        then: fn () => require __DIR__.'/../../common/bootstrap.php')
    ->withMiddleware(function (Middleware $middleware): void {
        // the token is already signed: the cookie that carries it is not encrypted
        $middleware->encryptCookies(except: ['token']);
        // an anonymous visitor who requests a protected page: off to /login
        $middleware->redirectGuestsTo('/login');
    })
    ->withExceptions()
    ->booted(function () {
        // the [jwt] guard driver (see config/auth.php): a function that receives the
        // request, and returns the authenticated user, or null for an anonymous visitor
        Auth::viaRequest('jwt', function (Request $request) {
            $token = $request->cookie(config('jwt.cookie'));
            if ($token === null) {
                return null;
            }
            $user = app(AuthService::class)->check($token);
            if ($user === null) {
                // a cookie present but invalid (expired, forged token): we delete it
                Cookie::queue(Cookie::forget(config('jwt.cookie')));
            }
            return $user;
        });
        // a named AUTHORIZATION [admin]: the function receives the authenticated
        // user, and says whether they have the requested right
        Gate::define('admin', fn (User $user) => $user->role === 'ADMIN');
    })
    ->create();
  • Line 44: The [Gate] facade (a “gate”) defines an authorization named [admin]. The function receives the authenticated user (and, optionally, other parameters: the object to be accessed, for example), and returns [true] if the user has the requested permission. A permission can be verified in several ways: [Gate::allows('admin')] or [Gate::authorize('admin')] (which throws an exception, 403) in the code, [@can('admin')] in the views, and the [can:admin] middleware on the routes. For a larger number of rules, [Laravel] suggests grouping them by template class into policies (*policies*).

8.2.2. The routing table and the controller

<?php
// routes/web.php: the routes of example 26

use App\Http\Controllers\AdminController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AuthController::class, 'home']);
Route::get('login', [AuthController::class, 'form']);
Route::post('login', [AuthController::class, 'login']);
Route::post('logout', [AuthController::class, 'logout']);

// the [auth] middleware: the route is accessible only to an authenticated user
Route::get('profile', [ProfileController::class, 'display'])->middleware('auth');

// two middlewares: [auth] (an authenticated user), then [can:admin] (who has
// the [admin] authorization, see bootstrap/app.php)
Route::get('admin', [AdminController::class, 'display'])->middleware(['auth', 'can:admin']);
  • line 19: two middleware components, executed in order: [auth] verifies that a user is authenticated (otherwise, redirects to the login page), then [can:admin] verifies that the user has permission [admin] (otherwise, a 403 error, *Forbidden*). The text following [:] is the middleware parameter, which [Laravel] passes to it after [$next]. The difference between the two denial responses is significant: for a user who is authenticated but does not have the requested role, logging in would serve no purpose, since they are already authenticated.
<?php
// app/Http/Controllers/AdminController.php: a page reserved for the ADMIN role

namespace App\Http\Controllers;

use App\Auth\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\View\View;

class AdminController
{
    public function display(#[CurrentUser] User $user): View
    {
        return view('admin.admin', ['name' => $user->name]);
    }
}

The controller performs no checks: that is the responsibility of the middleware. [Laravel] also allows you to specify middleware directly on the action itself, using attributes: [#[Middleware('auth')]], [#[Authorize('admin')]].

8.2.3. Views

@extends('layout')
{{-- the page of the 403 error, which Laravel displays by itself: a view
     errors/<code>.blade.php replaces the standard error page of this code --}}

@section('content')
  <div class="alert alert-danger">
    <h1 class="h4">Error 403</h1>
    <p class="mb-0">Access denied: this page is reserved for administrators.</p>
  </div>
  <p><a href="/profile">Back to the profile</a></p>
@endsection

The 403 error page: [Laravel] searches for a view [errors/<code>.blade.php] when a HTTP error occurs, and displays it in place of its standard page (see previous chapter). There is nothing to declare. The profile page provides a link to the administration page:

@extends('layout')

@section('content')
  <h1 class="h3">Hello {{ $user->name }}</h1>
  <p>Your role: <b>{{ $user->role === 'ADMIN' ? 'administrator' : 'user' }}</b>.</p>
  {{-- @can: the block is displayed only if the user has the [admin] authorization --}}
  @can('admin')
    <p><a href="/admin">The administration area</a></p>
  @else
    <p><a href="/admin">The administration area</a> (which is forbidden to you)</p>
  @endcan
  <p>The content of your token:</p>
  <table class="table table-sm w-auto">
    @foreach ($user->claims as $key => $value)
      <tr><td><code>{{ $key }}</code></td><td>{{ $value }}</td></tr>
    @endforeach
  </table>
  {{-- logging out: a POST form reduced to its button --}}
  <form method="post" action="/logout">
    @csrf
    <button class="btn btn-outline-secondary" type="submit">Log out</button>
  </form>
@endsection
  • Lines 7–11: The [@can] directive displays its block only if the user has permission; [@else] handles the opposite case. This prevents a link from being displayed that would lead to an error; however, this is merely a convenience measure: the real protection comes from the routing logic.

8.2.4. Execution

Marie, with the role USER, follows the link to the administration page:

Image

The administrator, on the other hand, has access to it:

Image

With [curl]: a user with role USER (the [token.txt] file, obtained by logging in as in Example 25, but on Example 26: the two examples sign their tokens with different keys), followed by an anonymous visitor:

curl -i -b token.txt http://localhost:8000/admin
curl -i http://localhost:8000/admin
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
        <div class="alert alert-danger">
    <h1 class="h4">Error 403</h1>
    <p class="mb-0">Access denied: this page is reserved for administrators.</p>
  </div>
...
HTTP/1.1 302 Found
Location: http://localhost:8000/login
...

Protecting routes one by one has a flaw: if one is overlooked, the page becomes accessible to everyone. The case study will reverse this logic: all routes are protected by default by a group, and only a few routes (the login page, the registration page) are excluded.

8.3. Example 27: CAPTCHA and Attempt Limits

A login form is a target: a program can try thousands of passwords per minute (brute-force attack). Two protections complement each other:

  • a CAPTCHA (*Completely Automated Public Turing test to tell Computers and Humans Apart*): an image containing a distorted code that is easy for a human to read but difficult for a program;
  • an attempt limit: after three failed attempts, the client’s address IP is blocked for one minute.

Example 27 builds on Example 25 and adds these two security measures.

8.3.1. The CAPTCHA image: app/Auth/CaptchaDrawing.php

<?php
// app/Auth/CaptchaDrawing.php: the drawing of a captcha, an SVG image (XML text)
// built by hand

namespace App\Auth;

class CaptchaDrawing
{
    public const WIDTH = 150;

    public const HEIGHT = 50;

    // The "font": each character is a list of strokes, each stroke a
    // sequence of x, y points in a 4 x 6 grid (y downwards).
    // Characters too easy to confuse with each other: excluded (0 O, 1 I L).
    private const FONT = [
        'A' => [[0, 6, 2, 0, 4, 6], [0.8, 3.8, 3.2, 3.8]],
        'B' => [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 3, 0, 4, 1, 4, 2, 3, 3]],
        'C' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
        'D' => [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
        'E' => [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
        'F' => [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
        'G' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5]],
        'H' => [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
        'J' => [[4, 0, 4, 5, 3, 6, 1, 6, 0, 5]],
        'K' => [[0, 0, 0, 6], [4, 0, 0, 3.5], [1.3, 2.6, 4, 6]],
        'M' => [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
        'N' => [[0, 6, 0, 0, 4, 6, 4, 0]],
        'P' => [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
        'Q' => [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.5, 4.5, 4, 6.3]],
        'R' => [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2, 3, 4, 6]],
        'S' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        'T' => [[0, 0, 4, 0], [2, 0, 2, 6]],
        'U' => [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
        'V' => [[0, 0, 2, 6, 4, 0]],
        'W' => [[0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0]],
        'X' => [[0, 0, 4, 6], [4, 0, 0, 6]],
        'Y' => [[0, 0, 2, 3, 4, 0], [2, 3, 2, 6]],
        'Z' => [[0, 0, 4, 0, 0, 6, 4, 6]],
        '2' => [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
        '3' => [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        '4' => [[3, 6, 3, 0, 0, 4, 4, 4]],
        '5' => [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        '6' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3]],
        '7' => [[0, 0, 4, 0, 1.5, 6]],
        '8' => [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5,
            4, 4, 3, 3]],
        '9' => [[4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5]],
    ];

    // a few dark colors, easy to read on the light background
    private const COLORS = ['#1f4e79', '#7b2d26', '#2e6b30', '#5b2c83', '#8a5a00', '#23395d', '#9c2a5b'];

    // a random text. random_int is a "cryptographic" generator,
    // unpredictable (unlike rand or mt_rand, whose sequence could be guessed)
    public static function randomText(int $length): string
    {
        $alphabet = array_keys(self::FONT);
        $text = '';
        for ($i = 0; $i < $length; $i++) {
            $text .= $alphabet[random_int(0, count($alphabet) - 1)];
        }
        return $text;
    }

    // a floating-point number, at random, between $minimum and $maximum
    private static function random(float $minimum, float $maximum): float
    {
        return $minimum + ($maximum - $minimum) * random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
    }

    private static function color(): string
    {
        return self::COLORS[random_int(0, count(self::COLORS) - 1)];
    }

    public static function svg(string $text): string
    {
        $l = self::WIDTH;
        $h = self::HEIGHT;
        $pieces = ["<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"$l\" height=\"$h\" viewBox=\"0 0 $l $h\">",
            '<rect width="100%" height="100%" fill="#f5f7fa"/>'];

        // the characters: each one enlarged, tilted, shifted, and each point
        // slightly moved at random
        $step = ($l - 20) / strlen($text);
        foreach (str_split($text) as $i => $character) {
            $scale = self::random(4.6, 5.6);    // size of a unit of the grid, in pixels
            $angle = self::random(-0.35, 0.35);   // rotation, in radians (about ±20°)
            $cx = 10 + $step * ($i + 0.5) + self::random(-3, 3);    // center of the character
            $cy = $h / 2 + self::random(-4, 4);
            foreach (self::FONT[$character] as $stroke) {
                $d = [];
                for ($k = 0; $k < count($stroke); $k += 2) {
                    // point of the grid, centered on (2, 3), distorted, rotated, placed
                    $x = ($stroke[$k] - 2 + self::random(-0.2, 0.2)) * $scale;
                    $y = ($stroke[$k + 1] - 3 + self::random(-0.2, 0.2)) * $scale;
                    $px = $cx + $x * cos($angle) - $y * sin($angle);
                    $py = $cy + $x * sin($angle) + $y * cos($angle);
                    $d[] = sprintf('%s%.1f %.1f', $k === 0 ? 'M' : 'L', $px, $py);
                }
                $pieces[] = sprintf('<path d="%s" fill="none" stroke="%s" stroke-width="%.1f" '
                    .'stroke-linecap="round" stroke-linejoin="round"/>',
                    implode(' ', $d), self::color(), self::random(2.2, 3));
            }
        }

        // 3 scrambling lines: curves that cross the image
        for ($n = 0; $n < 3; $n++) {
            $pieces[] = sprintf('<path d="M%.1f %.1f C%.1f %.1f %.1f %.1f %.1f %.1f" fill="none" '
                .'stroke="%s" stroke-width="1.3"/>',
                self::random(0, 20), self::random(5, 45), self::random(30, 60), self::random(0, 50),
                self::random(90, 120), self::random(0, 50), self::random(130, 150), self::random(5, 45),
                self::color());
        }
        $pieces[] = '</svg>';
        return implode('', $pieces);
    }
}

The image is in SVG format (*Scalable Vector Graphics*): a vector graphic described in XML—that is, text—which the server generates without a graphics library and which the browser displays directly on the page.

  • Lines 16–49: a minimalist “font”: each character is a list of strokes, and each stroke is a list of coordinates [x, y] in a 4 × 6 grid. Characters that are easily confused ([0] and [O], [1], [I], and [L]) are excluded;
  • Lines 56–64: a random text string, selected from the characters in the font ([array_keys], the keys in the table). The functions [rand] and [mt_rand] from [PHP] produce predictable number sequences (anyone who knows a few values can guess the next ones): they are suitable for simulations, not for security. [random_int] is a cryptographic generator; it is unpredictable;
  • lines 67–70: a random floating-point number, generated from a random integer ([PHP_INT_MAX] is the largest integer in [PHP]);
  • lines 77–118: the drawing. Each character is randomly scaled, rotated, and moved, and each of its points is slightly offset; each line becomes an element [<path>] of SVG ([M]: displacement, [L]: line). Three curves ([C]: Bézier curve) cross the image to interfere with recognition programs. The [sprintf] function formats a string: [%s] inserts a string into it, and [%.1f] inserts a number with one decimal place. [str_split] splits a string into characters.

8.3.2. The CAPTCHA service: app/Auth/CaptchaService.php

<?php
// app/Auth/CaptchaService.php: the pending captchas, kept by the server,
// in the cache (see example 20)

namespace App\Auth;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class CaptchaService
{
    // the lifetime of a captcha, in seconds
    private const LIFETIME = 600;

    public function generate(): Captcha
    {
        $text = CaptchaDrawing::randomText(5);
        // a random identifier, impossible to guess
        $id = Str::random(24);
        // the expected answer, in the cache, for 10 minutes: the cache will forget it
        // by itself afterwards
        Cache::put("captcha:$id", $text, self::LIFETIME);
        return new Captcha($id, CaptchaDrawing::svg($text));
    }

    // the captcha is CONSUMED in all cases: an image is used only once
    public function check(?string $id, ?string $answer): bool
    {
        // pull: reads the value AND removes it from the cache (null if it does not exist or has expired)
        $expected = $id ? Cache::pull("captcha:$id") : null;
        if ($expected === null || $answer === null) {
            return false;
        }
        return strcasecmp($expected, trim($answer)) === 0;
    }
}
  • Lines 15–24: a new CAPTCHA: random text, a random identifier that is impossible to guess ([Str::random] also uses a cryptographic generator), and the image. For each identifier, the server stores the expected response in the cache (example 20) for ten minutes: [Cache::put(key, value, seconds)]. After this time, the cache automatically discards it. The page receives only the identifier and the image: the answer never leaves the server. The class [Captcha] combines the identifier and the image;
  • lines 27–35: the verification. [Cache::pull] reads the value and removes it from the cache: the CAPTCHA is consumed in all cases, whether successful or not. Without this, a program could try all possible answers on the same image. The comparison ignores case ([strcasecmp] returns 0 for two equal strings, regardless of uppercase or lowercase letters) and spaces around the input ([trim]).

8.3.3. The controller: app/Http/Controllers/AuthController.php

<?php
// app/Http/Controllers/AuthController.php: login (with captcha and limitation of the attempts) and logout

namespace App\Http\Controllers;

use App\Auth\AuthService;
use App\Auth\CaptchaService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View;

class AuthController
{
    // 3 failures at most, for 60 seconds
    private const MAX_FAILURES = 3;

    private const BLOCK_DURATION = 60;

    public function __construct(private AuthService $auth, private CaptchaService $captchas)
    {
    }

    public function home(): RedirectResponse
    {
        return redirect('/profile');
    }

    public function form(): View
    {
        return $this->loginPage();
    }

    public function login(Request $request): View|RedirectResponse
    {
        $login = $request->input('login');
        // the key of the failure counter: the IP address of the client
        $key = 'login:'.$request->ip();

        // 1. a blocked address is not even listened to
        if (RateLimiter::tooManyAttempts($key, self::MAX_FAILURES)) {
            $seconds = RateLimiter::availableIn($key);
            return $this->loginPage($login, "Too many attempts: try again in $seconds s.");
        }
        // 2. the captcha first (cheap), the password (bcrypt, expensive) afterwards
        if (! $this->captchas->check($request->input('captchaId'), $request->input('captcha'))) {
            RateLimiter::hit($key, self::BLOCK_DURATION);
            return $this->loginPage($login, 'The image code is incorrect.');
        }
        $user = $this->auth->validate($login, $request->input('password'));
        if ($user === null) {
            RateLimiter::hit($key, self::BLOCK_DURATION);
            return $this->loginPage($login, 'Incorrect login or password.');
        }
        RateLimiter::clear($key);
        return redirect('/profile')->withCookie(cookie(config('jwt.cookie'),
            $this->auth->createToken($user), config('jwt.duration'),
            httpOnly: true, sameSite: 'lax'));
    }

    public function logout(): RedirectResponse
    {
        return redirect('/login')->withoutCookie(config('jwt.cookie'));
    }

    // each display of the form builds a NEW captcha
    private function loginPage(?string $login = null, ?string $error = null): View
    {
        return view('auth.login', ['login' => $login, 'error' => $error,
            'captcha' => $this->captchas->generate()]);
    }
}
  • line 38: The limit is handled by the [RateLimiter] facade of [Laravel], which counts “attempts” under a key in the cache. The key here is the client’s address IP;
  • lines 41–44: A blocked address is not even listened to. [tooManyAttempts(key, 3)] is true if the key has reached 3 attempts; [availableIn] returns the number of seconds remaining before the block ends;
  • lines 46–54: The CAPTCHA is verified before the password; verifying it costs almost nothing, whereas [bcrypt] is intentionally slow. A program that enters the wrong CAPTCHA would therefore not trigger [bcrypt]. Each failure—whether for the CAPTCHA or the password—is counted: [RateLimiter::hit(key, 60)] adds one attempt and sets the counter’s lifetime to 60 seconds. On the third failure, the address is therefore blocked until the end of those 60 seconds, counted from the first failure;
  • line 55: a successful login clears the failed attempts;
  • lines 67–71: Each time the form is displayed, a new CAPTCHA is generated, even after a failed attempt.

[Laravel] also provides a middleware component, [throttle], which limits the number of requests for a route ([->middleware('throttle:5,1')]: 5 requests per minute); we chose to count only the failed attempts, so as not to inconvenience a user who is logging in normally.

8.3.4. The view: resources/views/auth/login.blade.php

@extends('layout')

@section('content')
  <h1 class="h3">Login</h1>
  <form method="post" action="/login">
    @csrf
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ $login }}" required>
    </div>
    <div class="mb-2">
      <label class="form-label" for="password">Password</label>
      <input class="form-control" type="password" id="password" name="password" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="captcha">Copy the code of the image</label>
      {{-- the SVG image, built by the server: inserted without escaping --}}
      <div class="mb-1">{!! $captcha->svg !!}</div>
      {{-- the captcha identifier travels in a hidden field; the answer stays on the server --}}
      <input type="hidden" name="captchaId" value="{{ $captcha->id }}">
      <input class="form-control" id="captcha" name="captcha" autocomplete="off" required>
    </div>
    <button class="btn btn-primary" type="submit">Log in</button>
  </form>
  @if ($error)
    <div class="alert alert-danger mt-3">{{ $error }}</div>
  @endif
  <p class="text-muted small mt-3">Test accounts (password = login): marie (USER), admin (ADMIN).</p>
@endsection
  • Line 18: The image SVG is inserted into the page as-is, without escaping (Example 14). This is legitimate: this HTML was generated by the server and contains no user input;
  • Line 20: The captcha ID, in a hidden field;
  • line 21: the browser does not suggest any previous entries for this field.

8.3.5. Execution

Image

An incorrectly copied code:

Image

After three failed attempts, the address is blocked:

Image

With [curl], four attempts using a made-up CAPTCHA ID (after obtaining, as in Example 25, the session cookie and the anti-CSRF token):

curl -b cookies.txt -d "_token=...&login=marie&password=marie&captchaId=abc&captcha=XXXXX" http://localhost:8000/login
1
2
3
4
      <div class="alert alert-danger mt-3">The image code is incorrect.</div>
      <div class="alert alert-danger mt-3">The image code is incorrect.</div>
      <div class="alert alert-danger mt-3">The image code is incorrect.</div>
      <div class="alert alert-danger mt-3">Too many attempts: try again in 60 s.</div>

Even with the correct password, the fourth attempt is rejected without being processed.