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.
Identity proof must be included with every request. [Symfony] typically stores the logged-in user in its session (see the section 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], [Laravel]), 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 (this is the principle behind the signed cookie in Example 23). 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.
[Symfony] has a comprehensive security component, integrated by SecurityBundle, whose elements we will use as-is:
- the application’s URL are distributed across firewalls (firewalls), each with its own method of authenticating users;
- authentication is handled by authenticators (authenticators): objects that determine the user’s identity for each request. [Symfony] provides several of these (login form, session, API token, etc.); we will write our own, which reads the JWT token. The rest of the application sees only the result: the authenticated user, provided by [#[CurrentUser]], [$this->getUser()], and, in the views, [app.user];
- users are provided by a user provider, and their passwords are hashed by the PasswordHasher component (algorithm [bcrypt]);
- authorizations are role-based: page protection is described by access control rules ([access_control]) or by the [#[IsGranted]] attribute of actions; views check for a right using [is_granted];
- Login attempts are limited by the RateLimiter component (Example 27).
All of this is accomplished by kernel event listeners (previous chapter): the firewall listens for [kernel.request] (priority 8), [#[IsGranted]] is read on [kernel.controller_arguments], and access denials are exceptions, handled on [kernel.exception].
Token generation and verification are handled by the [firebase/php-jwt] library, the most commonly used one in [PHP], installed by the [composer.json] file from the examples. (There is a widely used bundle for tokens—JWT and [LexikJWTAuthenticationBundle]—designed for API; for a web application, just a few lines are sufficient, and they better illustrate the mechanism.)
8.1. Example 25: Login, Token, Protected Page
8.1.1. Configuration: .env, bundles.php, and framework.yaml
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 contains three new lines (a comment, followed by two variables):
The standard requires that the algorithm used (HMAC-SHA256) have a key of at least 256 bits, or 32 bytes; the library rejects any shorter key. The code itself does not read the [.env] file directly: the [Dotenv] component (example 01) has converted it into environment variables, which the container provides to the services that request them (we’ll see this with the [AuthService] service). A real environment variable takes precedence over the [.env] file: under [Linux] or [macOS], the application would be launched with a different key as follows:
and under [Windows] (PowerShell):
(The option [-d variables_order=EGPCS] instructs [PHP] to populate the array [$_ENV] with environment variables, which its default configuration does not always do.) In production, secrets are often entrusted to the [Symfony] vault (secrets vault, command [bin/console secrets:set]), which stores them encrypted within the project.
SecurityBundle is added to the list of bundles:
The session is used here solely for the anti-CSRF token of the login form (Example 21):
8.1.2. Security configuration: config/packages/security.yaml
- lines 4–6: the password hashing algorithm for users from the [User] class: [bcrypt]. (The value [auto] would select the best available algorithm and be able to verify hashes from other algorithms);
- lines 9–11: the user provider—the service that finds a user based on their ID (here, our class [UsersService]). [Symfony] provides several: [memory] (a list written in this file), [entity] (a database table; see case study)...;
- Lines 14–27: the firewalls. Each one applies to the URL entries that match its pattern ([pattern]), with the first matching entry being selected. The first, [resources], covers the files of [Bootstrap] (Example 15), with no security ([security: false]): there is no need to look for a user to serve a stylesheet. The second, [main], has no specific reason: it covers all other URL files;
- line 23: the firewall is stateless (stateless): the authenticated user is not stored in the session; they are identified with each request based on their token;
- Lines 26–27: the firewall’s authenticator, our class [TokenAuthenticator];
- Lines 31–32: Access control: a list of rules, examined in order, of which the first one—corresponding to URL—applies. The URL rules that begin with [/profile] require an authenticated user ([IS_AUTHENTICATED]); the others are public.
In a standard project, this file is written by [Symfony Flex] when the bundle ([composer require security]) is installed, and the [bin/console make:user] command from [MakerBundle] writes the user class and its provider.
8.1.3. Users: src/Security/User.php and UsersService.php
- Line 11: a user. [Symfony] expects authenticated users to implement the [UserInterface] interface: its methods provide it with the user’s ID and roles. The [PasswordAuthenticatedUserInterface] interface adds the hashed password. In a typical application, the [Doctrine] entity in the user table (next chapter) implements these methods;
- Lines 13–23: 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 in encrypted form: if the database were stolen, the passwords would be stolen as well (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 32–35: roles, as defined by [Symfony]: strings that begin with [ROLE_]. The role [USER] from the case study becomes [ROLE_USER].
- Lines 17–20: Two test users, hard-coded (Example 28 and the case study will store them in a database): for each login, the name, role, and password hash. The hash is generated using the bcrypt algorithm, 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 16), or by the command [bin/console security:hash-password] from [Symfony]: Calculating them with every request would be an unnecessary waste of time, since the application is rebuilt with every request. [bcrypt] only takes into account the first 72 bytes of a password: the case study accounts for this;
- lines 22–29: 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;
- lines 32–47: the three methods of the [UserProviderInterface] interface, which make the class a user provider: [loadUserByIdentifier] returns the user associated with an identifier, or throws the [UserNotFoundException] exception; [refreshUser] reloads the user stored in the session (unnecessary with a stateless firewall); [supportsClass] specifies the class of the provided users. The operator [??] followed by [throw] raises the exception when the left-hand side expression evaluates to [null].
8.1.4. The authentication service: src/Security/AuthService.php
- Lines 17–26: The service receives the user provider, the password hashing service ([UserPasswordHasherInterface], which applies the algorithm selected in [security.yaml]), and two configuration values. The attribute [#[Autowire(env: 'NAME')]] provides the value of an environment variable; a prefix converts it: [int:] to an integer (there are others: [bool:], [json:], [base64:]...). The lifetime is a read-only public property: the controller will use it;
- lines 31–41: verification of a username and password. [isPasswordValid] 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 44–56: Token generation. The token contains an array of statements (claims) about the user. Some have standardized names: [sub] (subject), the user ID; [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 algorithm [HS256] (HMAC-SHA256, a signature using a shared secret key);
- lines 60–69: 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 claims (parameter named [claims]).
8.1.5. The authenticator: src/Security/TokenAuthenticator.php
For each request that passes through the firewall, [Symfony] calls the authenticator’s methods in this order:
- lines 28–31: [supports]: Is the authenticator involved in this request? Yes, if it contains the token cookie. Otherwise, nothing happens: the visitor remains anonymous;
- lines 35–44: [authenticate] constructs a passport: the user’s identity (a badge, [UserBadge]), and the credentials to be verified. A standard login form would include the entered password in the passport, which [Symfony] would verify itself. Here, the proof is the token, already verified by [AuthService]: the passport validates itself ([SelfValidatingPassport]). The badge contains the login and a function that identifies the user: in this case, the user described by the token, without consulting the user list (without this function, [Symfony] would request the user from the provider). An invalid token raises an authentication exception;
- lines 47–50: [onAuthenticationSuccess]: authentication succeeded. Returning [null] allows the request to proceed to the action with an authenticated user. (A login form would redirect to the home page here);
- lines 53–58: [onAuthenticationFailure]: authentication failed (token expired or forged). The response redirects to the login page and takes the opportunity to clear the cookie: [clearCookie] adds an empty, already expired cookie to the response;
- lines 62–65: [start] is the method of the [AuthenticationEntryPointInterface] interface: the firewall’s entry point, i.e., the response sent to an anonymous visitor requesting a protected page. In this case, a redirect to the login page.
The authenticator does not deny access: it determines who the user is, or determines that the user is anonymous. This is authentication. Denials are the responsibility of access control.
8.1.6. The controllers
- lines 36–38: The login form is protected against forged requests, like all forms (Example 21);
- lines 40–45: the login process. If it 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 49–54: 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; its expiration date ([withExpires], here a number of seconds since 1970) is the same as the token’s. In production, to HTTPS, we would add [withSecure(true)]: the cookie would never be sent over an unencrypted connection;
- Lines 59–68: Logging out deletes the cookie ([clearCookie]). This is an action POST, protected by an anti-CSRF token: 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.
[Symfony] can also handle form-based login (the [form_login] authenticator) and logout (the [logout] firewall option) on its own; but they store the user in the session, whereas we want a token.
- line 18: the [#[CurrentUser]] attribute provides the authenticated user, the one established by the authenticator. The action is protected by the rule [access_control] from [security.yaml]: it is guaranteed to receive a user. We would also get it via [$this->getUser()].
8.1.7. The views
- line 6: the form’s anti-CSRF token (Example 21);
- line 9: the entered login is redisplayed after a failure ([??]: an empty string when the variable [login] does not exist).
- lines 8–10: the token’s content, displayed for illustrative purposes. A loop from [for] to [Twig] can iterate through the keys and values of an associative array ([for key, value in ...]);
- lines 13–16: Logout, via a form reduced to a single button.
The authenticated user is also available in all views under the name [app.user].
8.1.8. 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 21), 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 [é] is written in its escaped form, JSON, [\u00e9].) 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]*** with ***[ADMIN]*** in the content, 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. The configuration: config/packages/security.yaml
- Lines 12–13: the role hierarchy: the role [ROLE_ADMIN] includes the role [ROLE_USER]. An administrator thus has all the rights of a user without needing to be assigned both roles;
- Line 25: The firewall access denial handler ([access_denied_handler]): the class that generates the response sent to an authenticated user who requests a page they are not authorized to view;
- Lines 27–31: The page [/admin] could be protected here with an additional rule; instead, we protect it in its controller to demonstrate an alternative approach.
The difference between the two denial responses is important: an anonymous visitor requesting a protected page is redirected to the firewall’s entry point (the login page, Example 25); an authenticated user who does not have the requested role receives a 403 error (Forbidden): logging in would be pointless, since they are already authenticated.
8.2.2. Access Denied: src/Security/AccessDenied.php
- line 13: the class implements the [AccessDeniedHandlerInterface] interface, whose sole method, [handle], returns the denial response;
- line 21: the 403 error page, generated by [Twig] (like the [ErrorPage] listener in Example 24), with the code 403 ([Response::HTTP_FORBIDDEN], a constant of the [Response] class).
Without this handler, the denial would result in a [AccessDeniedHttpException] exception, and the user would see the 403 error page for [Symfony] (detailed in debug mode). The [templates/errors/403.html.twig] page is a standard error page, with a link back to the profile.
8.2.3. The controller: src/Controller/AdminController.php
- Line 17: The [#[IsGranted]] attribute allows the action to be executed only by a user who has the required permission, in this case the [ROLE_ADMIN] role. An anonymous visitor is redirected to the entry point; an authenticated user without this role is denied access. When applied to the class, the attribute would protect all of its actions.
The controller does not perform any checks itself. In the code, a permission would be checked using [$this->isGranted('ROLE_ADMIN')] (which returns a Boolean) or [$this->denyAccessUnlessGranted('ROLE_ADMIN')] (which throws a denial exception). For rules that are more granular than a role (“a user can only edit their own appointments”), [Symfony] allows you to define “voters” (voters): classes that determine a permission, which [#[IsGranted]] and [is_granted] query as if they were roles.
8.2.4. The profile view
- Lines 7–11: The function [is_granted] from [Twig] checks a user’s permission. This prevents a link from being displayed that would lead to an error; however, this is merely a convenience feature: the real protection comes from the action itself.
8.2.5. 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 (logged in as in Example 25, with cookies stored in the file [token.txt]), 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 URL pages are protected by default through a general [access_control] rule, and only a few URL pages (the login page, the registration page) are exempt.
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: src/Security/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 the SVG format (Scalable Vector Graphics): a vector drawing described in XML—that is, text—which the server generates without a graphics library, and which the browser displays directly on the page. This class is pure [PHP], with nothing specific to a framework: it is the same as the [Laravel] version of this course.
- Lines 16–49: a minimalist “font”: each character is a list of strokes, and each stroke is a list of [x, y] coordinates in a 4 × 6 grid. Characters that are easily confused ([0] and [O], [1], [I], and [L]) are excluded;
- Lines 56–64: a random string selected from the characters in the font ([array_keys], the keys in the table). The functions [rand] and [mt_rand] of [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 the 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.
The class [Captcha] combines a CAPTCHA’s ID with its image:
8.3.2. The CAPTCHA service: src/Security/CaptchaService.php
- Lines 18–29: a new CAPTCHA: random text, a random and unguessable identifier, and the image. For each identifier, the server stores the expected response in the cache (example 20) for ten minutes: the [expiresAfter] method of the cache element sets its lifetime in seconds. After this time, the cache automatically discards it. The page receives only the identifier and the image: the response never leaves the server;
- lines 32–46: the validation. The identifier comes from the client: we first verify that it consists of hexadecimal digits ([ctype_xdigit]), since certain characters ([{}()/\@:]) are prohibited in cache keys, which would throw an exception. The element is read, then removed from the cache ([deleteItem]): 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. Attempt limit: config/packages/framework.yaml
- line 11: locks (example 20): the limiter uses these to ensure that two simultaneous requests do not skew its count;
- lines 14–18: a rate limiter, named [login]. A rate limiter distributes “tokens” (unrelated to the token JWT): each restricted action consumes one, and when there are no more left, the action is denied. The [fixed_window] policy (fixed window) grants 3 tokens per 60-second period, counted from the first token consumed. There are other policies: [sliding_window] (a sliding window, more regular), [token_bucket] (a bucket that fills at a given rate). The counters are stored in the application cache.
For each declared limiter, [Symfony] creates a limiter factory service ([RateLimiterFactoryInterface]), which is obtained via injection under a parameter name consisting of the limiter's name followed by [Limiter]: [$loginLimiter].
8.3.4. The controller: src/Controller/AuthController.php
- lines 21–26: the controller receives the factory for the limiter [login];
- line 49: this client’s limiter, [create(key)], returns a limiter that has its own counter under this key—in this case, the client’s address IP ([getClientIp()]);
- lines 53–57: A blocked address is not even listened to. [consume(0)] queries the limiter without consuming anything; the [RateLimit] object it returns provides the number of remaining tokens ([getRemainingTokens()]) and the date when the action will be permitted again ([getRetryAfter()]), from which we can deduce the number of seconds to wait;
- Lines 60–68: 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—consumes a token ([consume()], which consumes one by default). After the third failure, no tokens remain: the address is blocked until the end of the 60-second window, counted from the first failure;
- line 70: a successful login clears the failed attempts ([reset()]);
- lines 91–95: Each time the form is displayed, a new CAPTCHA is generated, even after a failed attempt.
SecurityBundle also provides a built-in connection limit (the [login_throttling] firewall option), which applies to its own login authenticators ([form_login]...), not to our action. To limit the number of requests for a route, we would use a limiter in the same way, either within the action or in a listener for [kernel.request]. We chose to count only the failed attempts, so as not to inconvenience a user who is logging in normally.
8.3.5. The view: templates/auth/login.html.twig
- Line 18: The image SVG is inserted into the page as-is, without escaping (filter [raw], example 14). This is legitimate: this HTML was generated by the server; it does not contain any 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.6. 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.