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
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:
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:
- 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:
[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:
and under [Windows] (PowerShell):
8.1.2. Users: app/Auth/User.php and UsersService.php
- 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.
- 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
- 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
- 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
- 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.
- 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:
- 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
- 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.
- 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:
An incorrect password:

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:
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:
The expiration time is one hour (3,600 seconds) after creation. In the browser, the profile page displays this same content:

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

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

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:
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | |
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
- 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
- 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
- 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

An incorrectly copied code:

After three failed attempts, the address is blocked:

With [curl], four attempts using a made-up CAPTCHA ID (after obtaining, as in Example 25, the session cookie and the anti-CSRF token):
Even with the correct password, the fourth attempt is rejected without being processed.