I explain Symfony’s CSRF protection in detail (particularly the stateless strategy) in this article, because this was a real pain to figure out myself, and I really wanted to know how it worked because I browse the web with Javascript disabled by default.
What is Cross-Site Request Forgery?
Cross-Site Request Forgery (CSRF) is explained well in the Symfony docs, but briefly, it is where a malicious page tricks a user into submitting a form on another website, usually without them even realising they’ve done it if it’s submitted via Javascript.
The malicious page will typically hide the form fields with CSS and convince the unwitting user to submit an innocuous looking form on the page which actually submits the hidden, malicious form to a server elsewhere (in this case, your server). This attack works even without Javascript enabled.
It’s a nasty attack. The attacker is subverting the user’s browser, which is a HTTP client, to act against their interests.
There isn’t an obvious way for a user to protect themselves from this. Thus, the duty falls to us to make sure these requests are actually coming from the user and not the attacker commandeering the user’s browser.
How do you protect your users against CSRF?
The general approach to preventing CSRF is to trust the browser but not the request. This gives us a few options for protecting against CSRF, all of which Symfony implements:
Stateful CSRF Tokens
The stateful method involves:
- Generating a matching set of values called “tokens”.
- Storing the first token in the session.
- Creating a hidden
<input>field on your form that contains the name of the token field and the value of the second token. - Comparing the second token to the first token on submission of the form to ensure the token is correct.
This is implemented in vendor/symfony/security-csrf/CsrfTokenManager.php.
Because the token value is generated at the time the form is served to the user and stored server-side in the session for that particular user, there is no way1 for an attacker to obtain the token for that particular user and store it on their form on their own website.
When the user unwittingly submits the malicious form and their browser sends the form to your webserver, Symfony will reject it because the token is missing or incorrect.
This is pretty simple and secure.
Stateless CSRF Tokens
Symfony 7.2 added stateless CSRF protection, so this is quite recent. The stateless strategy checks a few more things and is implemented both client-side and server-side, but does not generate or store tokens server-side. It checks two request headers and generates the CSRF token with Javascript from a Stimulus controller.
You need to have Stimulus enabled, configured, and for the csrf_protection_controller Stimulus controller to exist for stateless CSRF protection to reach maximum security. Otherwise, your server will only ever check Origin and Referer headers and not the actual token. Turbo is optional.
This is implemented in vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php.
Checking Origin & Referer Headers
Firstly, yes, “Referer” is misspelled. It was misspelled in the RFC. And now we all must suffer.
At a minimum, stateless protection in Symfony will always check that the Referer or Origin header starts with the same domain name as the domain name the request is targeting. These are set by the browser when the request is made and can’t be injected by the attacker.
Some users disable Referer headers in their browser, but there isn’t a reason to disable the Origin header, so this will still work if only the Origin header is correct. The browser needs to send at least one of these; not both.
It’s possible to circumvent validation of Referer and Origin headers depending on the implementation, but Symfony’s implementation seems correct: https://portswigger.net/web-security/csrf/bypassing-referer-based-defenses
The implementation as of Symfony 7.4:
// vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php
private function isValidOrigin(Request $request): ?bool
{
$target = $request->getSchemeAndHttpHost().'/';
$source = 'null';
foreach (['Origin', 'Referer'] as $header) {
if (!$request->headers->has($header)) {
continue;
}
$source = $request->headers->get($header);
if (str_starts_with($source.'/', $target)) {
return true;
}
}
return 'null' === $source ? null : false;
}
Client-side Anti-CSRF Token
The stateless Anti-CSRF Token generation strategy is pretty involved.
There is a Stimulus controller called csrf_protection_controller.js inside the assets/controllers directory. This works in concert with the SameOriginCsrfTokenManager service on Symfony’s side to check whether the user’s browser contains a cookie with a token that matches the token generated for the <input> field. It also does a lot of other things, like checking the Referer and Origin headers, and checking that the token is a minimum length.
The flow looks kind of like this:
- The page is generated with a CSRF token field that calls the
getToken()method on theSameOriginCsrfTokenManagerservice, and if the identifier for the field is one that has been configured to be managed statelessly, it creates a CsrfToken with the ID given to it and with a value of the cookie name, which iscsrf-token. No cryptographically secure value has been generated yet; if this were the stateful strategy, thevalueattribute of the CSRF token field would have been set to a cryptographically secure value, but with the stateless strategy, it is just the cookie name that the Stimulus controller can use later. You can check this with your browser’s dev tools. - The Stimulus controller activates and attaches an event listener to the submit event to tell it to run
generateCsrfToken()when a form is submitted. - The user submits a form to your server (hopefully on your site!) with the CSRF field.
- The Stimulus controller generates a cryptographically secure value using the browser’s
window.crypto, saves it to asamesite='Strict'and Secure cookie given to the browser, and resubmits the form. This is what the docs refer to as a “Double Submit”. - Symfony handles the request and runs
isCsrfTokenValid()from AbstractController on the form field, which is then handled bySameOriginCsrfTokenManager, which inspects the request and validates either of the Origin or Referer headers, and decides whether to validate the token from Stimulus or not. - If valid, the code passes; if not, a response with an error is returned by Symfony. Either way, the cookie is then cleared so it can’t be used again.
This is the part of the code that checks whether the token field submitted in the form matches the cookie by inspecting all of the cookies sent in the request and comparing that with the submitted token field:
// vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php
if (($request->cookies->all()[$cookieName.'_'.$token] ?? null) !== $this->cookieName && !($this->checkHeader && $request->headers->has($this->cookieName))) {
return null;
}
If the user did not have Javascript enabled when logging into your site the first time, the client-side CSRF token will not be checked for validity. If, however, the user at any point filled out a statelessly CSRF-protected form on your site with Javascript enabled in their current session, all further form submissions will check the token for validity.
However, this relies on the session, and if the session was destroyed at any point (for example, by garbage collection), then it will default back to not checking the CSRF token is valid for this user and only check Referer and Origin headers of the request.
Preventing Downgrades by Storing CSRF Validation Strategy in Session
There’s a section of code in the SameOriginCsrfTokenManager’s isTokenValid() method that checks whether the user had previously submitted a form with more checks passing, and ensures that future requests cannot pass with a lower amount of checks passing:
if ($previousCsrfProtection) {
if (!$isValidOrigin && (1 & ($previousCsrfProtection >> $shift))) {
$this->logger?->warning('CSRF validation failed: origin info was used in a previous request but is now missing.');
return false;
}
if (!$isValidDoubleSubmit && (2 & ($previousCsrfProtection >> $shift))) {
$this->logger?->warning('CSRF validation failed: double-submit info was used in a previous request but is now missing.');
return false;
}
}
This is designed to prevent downgrade attacks, and this information is stored in the user’s session.
Advantages Over Stateful CSRF
Stateless CSRF Protection is useful for caching. With stateful CSRF protection, there are various methods for caching pages with forms on them, but you don’t need to worry about that with stateless CSRF protection.
Stateless CSRF, at its best, is also more secure than the stateful strategy. It checks Referer and Origin headers properly, and it checks that there was a samesite='Strict' cookie sent by the user’s browser that matches the CSRF token field submitted in the form. The stateful strategy does not check for a samesite cookie.
Downgrade attacks mean that the stateless CSRF strategy only checks for Referer and Origin headers in the request, but so long as it does that properly, there should be no way2 for an attacker to fake those headers unless there’s a vulnerability in the browser.
Both strategies are secure, but stateful could be a better choice if you’re designing your service for users with Javascript disabled in mind.
Same Site Cookies
Another method is to set the “Same Site” attribute on your cookies to something stricter. This is best used in concert with the stateful strategy, as the stateless strategy already employs this method.
Setting your cookies to use same_site: Strict. This is the nuclear option. When you send cookies to the user with this attribute set to Strict, the browser won’t send these cookies to your server under any circumstances unless the request comes from the same domain.
This means that, for pages that require authentication to view, any link to your site from an external site like a search engine or a messaging app, will not send the REMEMBER_ME or PHPSESSID cookies the server needs to authenticate the user. And so, they will be redirected to the login page. This is generally not what you want, but it will certainly prevent a form being submitted to your server from another website and guarding against CSRF.
Instead, setting your cookies to same_site: Lax is more pragmatic. This means the user’s browser will still send cookies on normal GET requests. This allows for CSRF attacks on forms where GET requests are accepted, but prevents CSRF attacks that use POST, which is the majority of them.
By default, this is not set, and relies on the browser to do what it thinks is best with cookies. Firefox, Chrome, and Safari have different opinions about this. Lax is not the default in any browser except Chrome at this time.
You can set the Same Site attribute for all session cookies in your php.ini file by default, or in Symfony inside config/parameters/framework.yaml:
framework:
session:
enabled: true
cookie_samesite: 'lax'
Testing Stateless CSRF
You can test this with any form that has an <input> field for the CSRF token, which includes all forms created by the Symfony Forms component.
In Firefox (which is what I use; I’m sure other browsers have similar methods), you can turn off all of the identifiers that the stateless method checks for;
- Disable Javascript in your browser. You can do this most easily with uBlock Origin.
- Set
network.http.sendRefererHeaderto0to stop it from sending a Referer header. - Set
network.http.sendOriginHeaderto0to stop it from sending an Origin header.
Then, try to submit the form, and it should give you an invalid CSRF token error. If you were instead using the stateful strategy, this would still work as the CSRF token is generated by and validated by the server.
If you enable Javascript or start sending either a Referer or Origin header with the stateless strategy, then the CSRF token is evaluated as valid. You need one of these identifiers for the strategy to work.
You can employ both stateless and stateful CSRF protection by deciding which identifiers should be managed statelessly in config/packages/csrf.yaml under stateless_token_ids.
Manually Validating Stateless CSRF Tokens
If you want to manually validate stateless CSRF tokens outside of a Symfony form, here's a minimal example:
1. The frontend template code which generates the CSRF token and hooks it up to the csrf_protection_controller Stimulus controller:
// templates/admin/delete-user.html.twig
<form action="{{ path('delete_user', {id: user.id}) }}" method="post">
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('delete-user') }}">
<button type="submit" class="btn btn-danger">Delete User</button>
</form>
2. The Controller code which grabs the submitted token from the _csrf_token input field (make sure you've got the right name attribute on your input field!) and checks it with $this->isCsrfTokenValid():
// src/Controllers/UserController.php
#[Route('/users/{id:user}/delete', name: 'delete_user', methods: ['POST'])]
public function deleteUser(User $user, Request $request, EntityManagerInterface $em): Response
{
$submittedToken = $request->getPayload()->get('_csrf_token');
if ($this->isCsrfTokenValid('delete-user', $submittedToken)){
$em->remove($user);
$this->addFlash('success', 'User "' . $user->getEmail() . '" deleted.');
$em->flush();
return $this->redirectToRoute('show_users', [
]);
}
$this->addFlash('error', 'Could not delete user because the form submission was detected as unsafe. Make sure Javascript is enabled and your browser is sending Referer/Origin headers. Please try again.');
return $this->redirectToRoute('show_users');
}
3. The configuration file which adds the delete-user token ID to the stateless_token_ids array to enable stateless CSRF validation for that token:
// config/packages/framework.yaml
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout
- delete-user
If you don't add the token ID here, the previous two segments of code will still work; it will just validate CSRF tokens statefully rather than statelessly as Symfony has configured the stateful CSRF token manager to act as a fallback, which we'll see in the next section.
Try submitting the form, then jump into the profiler and head to the Logs section. Then click on the All Messages tab, and you will see something like this, which proves the statless CSRF token manager handled the request:
CSRF validation accepted using both origin and double-submit info.
You can also look at the chain of events by heading to the Performance tab and setting the threshold to 0. You should see that the SameOriginCsrfTokenManager was contacted rather than the CsrfTokenManager. The Symfony profiler is very useful for debugging.
How Does Symfony Determine Whether to Use Stateless or Stateful CSRF? We Can Find Out by Delving Into the Symfony Internals for the security.csrf.token_manager service…
This section is a more technical dive into how the CSRF Token Manager service is setup in Symfony.
isCsrfTokenValid is a protected method that comes from AbstractController inside vendor/symfony/framework-bundle/Controller/AbstractController.php. It calls the CsrfTokenManager service’s isTokenValid method:
// vendor/symfony/framework-bundle/Controller/AbstractController.php
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
if (!$this->container->has('security.csrf.token_manager')) {
throw new \LogicException('CSRF protection is not enabled in your application. Enable it with the "csrf_protection" key in "config/packages/framework.yaml".');
}
return $this->container->get('security.csrf.token_manager')->isTokenValid(new CsrfToken($id, $token));
}
We can see that the security.csrf.token_manager service is set with a Configurator inside vendor/symfony/framework-bundle/Resources/config/security_csrf.php:
// vendor/symfony/framework-bundle/Resources/config/security_csrf.php
return static function (ContainerConfigurator $container) {
$container->services()
->set('security.csrf.token_manager', CsrfTokenManager::class)
->args([
service('security.csrf.token_generator'),
service('security.csrf.token_storage'),
service('request_stack')->ignoreOnInvalid(),
])
And later on it is decorated with the SameOriginCsrfTokenManager, which is the TokenManager responsible for statelessly validating tokens:
->set('security.csrf.same_origin_token_manager', SameOriginCsrfTokenManager::class)
->decorate('security.csrf.token_manager')
->args([
service('request_stack'),
service('logger')->nullOnInvalid(),
service('.inner'),
abstract_arg('framework.csrf_protection.stateless_token_ids'),
abstract_arg('framework.csrf_protection.check_header'),
abstract_arg('framework.csrf_protection.cookie_name'),
])
->tag('monolog.logger', ['channel' => 'request'])
->tag('kernel.event_listener', ['event' => 'kernel.response', 'method' => 'onKernelResponse'])
;
So, this overrides the original stateful TokenManager but keeps it around in .inner. And then, in vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php, it’s passed to the __construct() method as fallbackCsrfTokenManager with the CsrfTokenManagerInterface type hint much like in this SymfonyCasts tutorial:
// vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php
public function __construct(
private RequestStack $requestStack,
private ?LoggerInterface $logger = null,
private ?CsrfTokenManagerInterface $fallbackCsrfTokenManager = null,
private array $tokenIds = [],
private int $checkHeader = self::CHECK_NO_HEADER,
private string $cookieName = 'csrf-token',
) {
And in its various methods which are all implemented as part of CsrfTokenManagerInterface, it passes everything to the fallbackCsrfTokenManager first if the $tokenId doesn’t match the set of stateless token Ids configured:
// vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php
public function getToken(string $tokenId): CsrfToken
{
if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) {
return $this->fallbackCsrfTokenManager->getToken($tokenId);
}
return new CsrfToken($tokenId, $this->cookieName);
}
// vendor/symfony/security-csrf/SameOriginCsrfTokenManager.php
public function isTokenValid(CsrfToken $token): bool
{
if (!isset($this->tokenIds[$token->getId()]) && $this->fallbackCsrfTokenManager) {
return $this->fallbackCsrfTokenManager->isTokenValid($token);
}
In Brief
You can use the same isCsrfTokenValid() method from AbstractController to validate stateless tokens. It will work for both stateful and stateless tokens. However, you need to have Stimulus enabled and the csrf_protection_controller.js controller available for it to validate stateless tokens.
Looking to dive into Desktop Linux?
A Guide to Desktop Linux in 2025, available for purchase as an eBook and in Print, is just the handbook you need to orient you on this journey.
Whether you're worried Windows no longer has your best interests at heart, or you're just plain curious, this guide will help you get up and running while avoiding all of the usual potholes and footguns along the way.
If you tried Linux a long time ago, you'll be glad to know all aspects of the desktop have only gotten better over time. You might not even recognise it!
It's not 1998 anymore. It's been over 30 years since Linux was first released, and the Linux Desktop has undergone seismic shifts in usability and capability over the years. This book is designed for the modern age and helps you take advantage of all the latest technology available for desktop Linux. Even if you've used Linux before, things have changed a lot in just the last few years.
That's not to say the Linux Desktop isn't as quirky as it has ever been, but the jankiness has been sanded off in many areas. There's plenty you can do on Linux, and yet it would be a lie to say there isn't a cost to that freedom most of your penguin friends aren't forthcoming about. This guide acknowledges the limitations of the Linux world and doesn't try to hide them from you.
In this book, you'll find chapters about:
- getting your new Linux distribution setup on your computer
- providing step-by-step guidance for installation, software compatibility,
- gaming, and productivity
- exploring the history of desktop Linux
- the bright yet uncertain future of Linux
Buy this book now and dip your toes into the weird and wonderful world of Desktop Linux. Just don’t blame me when your house starts filling up with penguin plushies and red hats.
-
See “CSRF Tokens and Compression Side-Channel Attacks” for how attackers can circumvent this, but note that Symfony has taken a mitigating measure against this: https://symfony.com/doc/current/security/csrf.html#csrf-tokens-and-compression-side-channel-attacks↩︎
-
I am not a security researcher. I am not a security researcher. I am not a security researcher. Do not take what I say as security advice, and do not assume that it is accurate.↩︎
Written by
James North
Project Manager and Company Director at Evolvepreneur.
Part-time system administrator for Evolve Systems Group.
Part-time writer and author of A Guide to Desktop Linux in 2025.
Symfony developer.
You Might Also Like
How to create professional audiobook .m4b files in under a minute using your WEB BROWSER!
James North
May 31, 2026

Comments (0)
No comments yet. Be the first to comment!