Le jeton CSRF : ce qu'il protège, quand il sert encore, et comment Symfony, Laravel, Django et Rails le gèrent
Cet article part d'un exercice, à faire dans le navigateur : Chercher avec un corps de requête grâce à la méthode QUERY →
« Le jeton CSRF est invalide. Veuillez renvoyer le formulaire. » chez Symfony, « 419 Page Expired » chez Laravel, « CSRF verification failed. Request aborted. » chez Django : tout développeur web a croisé l'une de ces erreurs. Le jeton CSRF fait partie de ces protections que tout le monde utilise, parce que le framework les active d'office, et que peu de gens savent expliquer. Je vois régulièrement en audit des routes exclues de la vérification « parce que ça bloquait », ou au contraire des API en jeton Bearer qui réclament un jeton CSRF pour rien.
Cet article fait le tour de la question : ce que le jeton protège vraiment, quand il est nécessaire à l'heure des cookies SameSite, et comment Symfony, Laravel, Django et Rails s'en chargent. Au passage, il explique un détail d'un exercice de Pratique récent : pourquoi une simple recherche avec la nouvelle méthode HTTP QUERY peut se voir réclamer un jeton.
L'attaque : faire agir le navigateur de la victime
CSRF signifie Cross-Site Request Forgery : falsification de requête intersite. L'attaquant ne vole rien. Il fait envoyer une requête par le navigateur de sa victime, vers un site où celle-ci est connectée.
Imaginons que vous soyez connecté à votre banque, et que vous visitiez ensuite une page piégée qui contient ceci :
<form action="https://banque.example/virement" method="POST" id="piege"> <input type="hidden" name="iban" value="FR76 ... compte de l'attaquant"> <input type="hidden" name="montant" value="2000"> </form> <script>document.getElementById('piege').submit();</script>
Le formulaire part tout seul vers la banque. Le navigateur y joint le cookie de session de la banque, comme pour n'importe quelle requête vers ce domaine. Côté serveur, rien ne distingue cette requête d'un vrai virement : bonne session, bons champs, bonne méthode.
Le point clé est là : le serveur reconnaît l'utilisateur à un cookie que le navigateur envoie tout seul. Tant que c'est le cas, n'importe quel site peut déclencher des actions en son nom. L'attaquant ne voit pas la réponse, la politique de même origine l'en empêche, mais il n'en a pas besoin : le virement est fait.
Ce que le jeton change
Le jeton CSRF est une valeur aléatoire que le serveur place dans ses propres pages, en général dans un champ caché, et qu'il exige à chaque envoi. Le site de l'attaquant peut fabriquer un formulaire vers la banque, mais il ne peut pas lire une page de la banque pour y prendre le jeton : la politique de même origine l'interdit. Sa requête arrive sans jeton valide, et le serveur la refuse.
Le jeton ne protège donc pas contre un attaquant qui peut exécuter du JavaScript sur votre propre domaine. Une faille XSS lit le jeton dans la page comme le ferait votre code. CSRF et XSS sont deux problèmes distincts, et le premier ne se règle pas sans le second.
Quand faut-il un jeton ?
La règle tient en une phrase : toute requête qui modifie quelque chose, sur une application qui authentifie par cookie.
- Les requêtes qui modifient :
POST,PUT,PATCH,DELETE. Les lectures (GET,HEAD,OPTIONS) n'en ont pas besoin, à condition qu'elles ne modifient vraiment rien. Une suppression déclenchée par un lien enGETn'est protégée par aucun framework : une simple balise<img src="https://site.example/plat/12/supprimer">sur une autre page suffit à la déclencher. - L'authentification par cookie : session classique, cookie « se souvenir de moi », ou jeton d'API rangé dans un cookie. C'est l'envoi automatique du cookie qui rend l'attaque possible.
- Le formulaire de connexion aussi. Un attaquant peut connecter sa victime à son compte, puis récupérer ce qu'elle y saisit (une adresse, une carte enregistrée). C'est le login CSRF. Symfony le couvre avec l'option
enable_csrfduform_login.
À l'inverse, une API qui authentifie par l'en-tête Authorization: Bearer ... n'a pas besoin de jeton CSRF : un autre site ne peut pas faire ajouter cet en-tête par le navigateur de la victime. C'est exactement pour cela que le groupe api de Laravel ne vérifie pas le jeton.
Et les cookies SameSite ?
L'attribut SameSite d'un cookie dit au navigateur de ne pas l'envoyer sur les requêtes qui viennent d'un autre site. Avec SameSite=Lax, le cookie de session ne part plus sur le POST du formulaire piégé : l'attaque ci-dessus échoue, même sans jeton. Symfony et Laravel posent SameSite=Lax sur leur cookie de session par défaut. Chrome applique aussi un « Lax par défaut » aux cookies qui ne précisent rien, en plus souple : pendant deux minutes après sa pose, le cookie accompagne encore un POST de premier niveau. Firefox et Safari ne font pas ce choix par défaut.
C'est une excellente deuxième ligne de défense, mais je ne la laisse jamais seule :
Laxlaisse passer les navigations de premier niveau enGET. Un lien cliqué depuis un autre site emporte le cookie. Une action déclenchée enGETreste donc exposée.- « Même site » n'est pas « même origine ».
blog.example.cometapp.example.comsont le même site pourSameSite. Un sous-domaine compromis, ou hébergé chez un tiers, contourne la protection. - Tous les navigateurs ne l'appliquent pas aux cookies qui ne précisent rien, et une configuration peut le désactiver sans que personne ne s'en aperçoive, par exemple
SameSite=Noneposé pour faire marcher une intégration dans une iframe.
Le cas de la méthode QUERY
La méthode HTTP QUERY, normalisée en 2026, est une lecture comme GET, mais avec ses critères dans le corps de la requête. Laravel 13.35 sait la router, et l'exercice de Pratique qui présente cette nouveauté contient une mise en garde sur le jeton CSRF. Le middleware CSRF de Laravel laisse passer les méthodes de lecture (GET, HEAD, OPTIONS) et vérifie toutes les autres. QUERY est une lecture selon sa RFC, mais Laravel ne la range pas encore parmi les lectures : déclarée dans routes/web.php, une route QUERY exige un jeton, comme un POST. Depuis Laravel 13, il y a une nuance : le middleware accepte aussi sans jeton une requête que le navigateur marque comme venant de la même origine (en-tête Sec-Fetch-Site: same-origin). Un QUERY envoyé par vos propres pages en HTTPS passe donc ; le 419 tombe surtout en développement local en http, où le navigateur n'envoie pas cet en-tête.
Est-ce un vrai risque d'en oublier un ? Pas vraiment. Un formulaire HTML ne sait envoyer que GET et POST, et une requête QUERY envoyée par fetch() depuis un autre site déclenche une requête préalable CORS (preflight), que votre serveur refuse s'il n'autorise pas cette origine. La vérification n'apporte rien ici, et elle gêne. D'où le conseil de l'exercice : une recherche en QUERY a sa place dans routes/api.php.
Comment les frameworks s'en chargent
Le principe est le même partout. Les différences portent sur trois points : où le jeton est rangé, comment on l'envoie, et ce qui se passe quand il manque.
Symfony
Le composant Form ajoute et vérifie le jeton tout seul, dans un champ caché _token. Pour un formulaire écrit à la main, on fabrique le jeton avec csrf_token('identifiant') dans Twig et on le contrôle avec isCsrfTokenValid() dans le contrôleur, ou avec l'attribut #[IsCsrfTokenValid] :
#[Route('/plat/{id}/supprimer', methods: ['POST'])] #[IsCsrfTokenValid(new Expression('"supprimer-plat-" ~ args["plat"].getId()'), tokenKey: '_token')] public function supprimer(Plat $plat): Response
Historiquement, le jeton est rangé en session. Depuis Symfony 7.2, la recette de symfony/form active des jetons sans état pour les formulaires : au lieu de comparer le jeton à la session, Symfony vérifie que les en-têtes Origin ou Referer correspondent à l'adresse du site, avec en secours un cookie posé par un petit contrôleur Stimulus. Plus de session à démarrer pour afficher un formulaire, ce qui rend les pages publiques plus faciles à mettre en cache.
Quand la vérification échoue, le formulaire n'est pas traité et porte l'erreur « Le jeton CSRF est invalide. Veuillez renvoyer le formulaire. ». J'ai détaillé ses causes dans la notion CSRF.
Laravel
Le middleware PreventRequestForgery (ValidateCsrfToken avant Laravel 13) s'applique à tout le groupe web. Depuis Laravel 13, il laisse d'abord passer les requêtes marquées Sec-Fetch-Site: same-origin (voir le cas de QUERY ci-dessus), puis vérifie le jeton. Dans un formulaire Blade, la directive @csrf ajoute le champ caché _token. Pour les appels JavaScript, Laravel accepte aussi le jeton dans l'en-tête X-CSRF-TOKEN, ou dans X-XSRF-TOKEN : Laravel pose un cookie XSRF-TOKEN que des bibliothèques comme Axios renvoient d'elles-mêmes dans cet en-tête.
Le jeton est rangé en session. Quand il manque ou ne correspond pas, Laravel répond 419 Page Expired, un code qui n'existe pas dans HTTP et que Laravel a choisi pour cette erreur. Les exclusions se déclarent dans bootstrap/app.php :
->withMiddleware(function (Middleware $middleware) { $middleware->preventRequestForgery(except: ['webhooks/stripe']); })
Exclure un webhook est légitime : il n'est pas authentifié par cookie, et il doit avoir sa propre vérification, une signature. Exclure une route parce qu'elle « renvoyait 419 », en revanche, c'est presque toujours masquer un @csrf oublié ou une session perdue.
Django
Le middleware CsrfViewMiddleware est actif par défaut. Dans un template, {% csrf_token %} ajoute le champ caché. Le jeton est rangé dans un cookie csrftoken (ou en session si on le demande), et le JavaScript le renvoie dans l'en-tête X-CSRFToken. Django vérifie en plus l'en-tête Origin, et en HTTPS le Referer : derrière un proxy ou avec plusieurs domaines, il faut déclarer les origines acceptées dans CSRF_TRUSTED_ORIGINS.
En cas d'échec, Django répond 403 avec la page « CSRF verification failed ».
Rails
ApplicationController hérite de la protection : protect_from_forgery est actif par défaut, et lève une exception en cas d'échec. La balise csrf_meta_tags place le jeton dans le <head>, et les aides de formulaire l'ajoutent d'elles-mêmes. Côté JavaScript, Rails UJS ou Turbo le renvoient dans l'en-tête X-CSRF-Token.
En cas d'échec, Rails lève ActionController::InvalidAuthenticityToken, qui donne une réponse 422.
En résumé
| Framework | Dans le formulaire | En-tête JavaScript | En cas d'échec |
|---|---|---|---|
| Symfony | champ _token (composant Form, csrf_token()) |
aucun par défaut (jetons sans état : géré par le contrôleur Stimulus) | erreur sur le formulaire, ou exception à la main |
| Laravel | @csrf |
X-CSRF-TOKEN ou X-XSRF-TOKEN |
419 Page Expired |
| Django | {% csrf_token %} |
X-CSRFToken |
403 |
| Rails | ajouté par les aides de formulaire | X-CSRF-Token |
422 |
Quand l'erreur tombe sur un vrai utilisateur
Quel que soit le framework, les mêmes causes reviennent quand un utilisateur légitime se fait refuser :
- La session a expiré. L'utilisateur a laissé l'onglet ouvert pendant la pause déjeuner. C'est la cause la plus fréquente, et la seule normale : affichez un message clair et proposez de renvoyer le formulaire.
- Le cookie de session n'arrive pas. Domaine du cookie mal configuré, cookie
Securesur un site servi enhttp, cookie bloqué par le navigateur. - La page est servie depuis un cache. Un cache HTTP distribue la même page, donc le même jeton, à tout le monde. Les jetons sans état de Symfony existent en partie pour ce cas.
- Plusieurs serveurs ne partagent pas leurs sessions. La page vient du serveur A, l'envoi arrive sur le serveur B, qui ne connaît pas le jeton.
- Le proxy n'est pas déclaré. L'application se croit en
httpalors que le navigateur annoncehttps: toutes les vérifications qui comparent l'origine échouent (Symfony sans état, Django).
Aucune de ces causes ne se règle en désactivant la vérification.
Ce que j'en retiens
Le jeton CSRF répond à un problème précis : un navigateur qui joint tout seul un cookie d'authentification à une requête qu'un autre site a déclenchée. Partout où ce n'est pas le cas, comme sur une API en Bearer ou une méthode qu'un autre site ne peut pas envoyer sans CORS, il n'apporte presque rien. Partout où c'est le cas, il reste nécessaire, même avec SameSite, qui n'est qu'une deuxième ligne de défense.
L'exercice sur la méthode QUERY se fait en quelques minutes dans le navigateur. Pour pratiquer le jeton lui-même côté Symfony, la notion CSRF rassemble les exercices qui le manipulent, du formulaire généré à un formulaire maudit dont le jeton ne correspond plus.