
Jeton CSRF : 146 cookies sur 269 sans SameSite, et pourquoi le jeton
reste nécessaire
Sur 269 cookies posés par 185 pages d'accueil, 146 n'ont pas d'attribut SameSite. On démonte l'attaque CSRF, ce que SameSite couvre vraiment, le jeton CSRF et le code Symfony, Django et Laravel.
Maxime J13 min de lecture
Un endpoint sans jeton CSRF, ça se voit à la revue de code
L'analyse statique SecAI marque les routes qui changent d'état sans protection anti-CSRF.
Repérer les CSRF au buildLe 17 février 2020, Chrome 80 a commencé à traiter tout cookie sans attribut SameSite comme SameSite=Lax (documentation Chromium). Du jour au lendemain, une bonne partie des attaques CSRF les plus bêtes ont cessé de fonctionner, sans que personne ne touche à une ligne de code applicatif. D'où une idée reçue tenace : le navigateur aurait réglé le problème, le jeton CSRF serait devenu du folklore. C'est faux.
Le Cross-Site Request Forgery (CSRF, ou falsification de requête inter-sites) consiste à faire exécuter au navigateur d'une victime une action sur un site où elle est connectée, à son insu. Pas de vol de mot de passe, pas d'injection de code : on abuse simplement du fait qu'un navigateur joint automatiquement les cookies de session à chaque requête vers un domaine, y compris quand c'est une page tierce qui déclenche cette requête. Le jeton CSRF est la réponse historique à ce problème, et SameSite n'en couvre qu'une partie.
Cadre légal
Une attaque CSRF, mécaniquement
Imaginez une banque en ligne dont le virement passe par un POST /transfer avec deux champs, to et amount. L'utilisateur est authentifié par un cookie de session que le serveur a posé à la connexion. Rien d'autre ne conditionne l'acceptation du virement. Un attaquant héberge une page anodine, y glisse un formulaire caché pointant vers cette route, et l'auto-soumet en JavaScript. Il suffit d'attirer la victime connectée sur cette page.
<!-- page piege.html, hebergee sur le site de l'attaquant -->
<form action="https://banque.example/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attaquant">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById('f').submit();</script>Quand le navigateur de la victime soumet ce formulaire, il agit comme si elle remplissait le vrai formulaire de la banque : il joint le cookie de session, et la requête part avec toutes les autorisations de la victime. Côté serveur, rien ne distingue cette requête d'une action légitime. Le virement passe. C'est tout le paradoxe de la CSRF : l'attaquant ne voit jamais la réponse (la same-origin policy la lui cache), mais il n'en a pas besoin : l'effet de bord, le virement, a déjà eu lieu.
banque.example/transfer, auto-soumis au chargement.banque.example.Trois conditions rendent l'attaque possible : l'action change un état (elle n'est pas qu'une lecture), elle repose sur un cookie envoyé automatiquement, et tous ses paramètres sont prévisibles par l'attaquant. Cassez n'importe laquelle des trois et l'attaque tombe. Le jeton CSRF s'attaque à la troisième.
Le jeton CSRF : ce qu'il protège
Le principe, appelé synchronizer token pattern dans la fiche OWASP sur la prévention CSRF, est d'introduire un paramètre imprévisible dans chaque requête sensible. Le serveur génère par session une valeur secrète, aléatoire, et l'insère dans le formulaire au moment du rendu. À la soumission, il vérifie que la requête porte bien cette valeur. Une page tierce n'a aucun moyen de la lire (la same-origin policy l'en empêche) ni de la deviner : sa requête forgée arrive sans le bon jeton, et le serveur la rejette.
meta.Un bon jeton est secret, unique par session et imprévisible, insiste OWASP. Il ne transite jamais dans une URL en GET (elle finit dans les logs, l'historique, l'en-tête Referer) et il n'a de sens que sur les verbes qui modifient un état. Corollaire non négociable de la même fiche : aucune opération sensible ne doit passer par une requête GET. Un endpoint GET /delete?id=42 reste forgeable via une simple balise <img>, jeton ou pas.
SameSite par défaut : ce qu'il bloque, ce qu'il laisse passer
L'attribut de cookie SameSite agit sur la deuxième condition (le cookie envoyé automatiquement) plutôt que sur la prévisibilité. Il prend trois valeurs. Strict ne joint le cookie que dans un contexte strictement first-party : même en cliquant un lien vers votre site depuis un autre, le cookie ne part pas au premier chargement. Lax, plus souple, laisse passer le cookie sur les navigations de premier niveau en méthode sûre (un clic de lien, donc un GET), mais le bloque sur les requêtes inter-sites comme un POST de formulaire ou un chargement d'image. None envoie le cookie partout et exige l'attribut Secure.
| Requête inter-sites | Strict | Lax | None |
|---|---|---|---|
| Clic sur un lien (navigation GET) | Non | Oui | Oui |
| POST auto-soumis (formulaire) | Non | Non | Oui |
<img>, <iframe>, fetch | Non | Non | Oui |
| Navigation same-site | Oui | Oui | Oui |
C'est le passage de "pas d'attribut = None de fait" à "pas d'attribut = Lax" dans Chrome 80 qui a désarmé tant d'attaques d'un coup : le formulaire auto-soumis de notre banque, en POST, n'emporte plus le cookie par défaut. Mais OWASP est clair, SameSite est une défense en profondeur, pas un rempart autonome. Trois raisons : Lax laisse toujours passer un GET de premier niveau, donc tout endpoint qui change un état en GET reste exposé ; les navigateurs anciens n'appliquent rien ; et un cookie posé avec SameSite sur le domaine parent reste "same-site" pour tous ses sous-domaines.
Deux précisions que les tutoriels oublient. Le défaut Lax est celui de Chrome et d'Edge : Firefox et Safari ne l'appliquent pas à un cookie sans attribut, qui part donc encore sur un POST inter-sites chez une partie de vos utilisateurs. Et Chrome lui-même garde une exception documentée par Chromium : un cookie sans attribut, posé depuis moins de deux minutes, accompagne encore un POST de premier niveau, pour ne pas casser certains parcours de connexion. Un cookie de session tout juste émis est précisément dans ce cas.
Ce que posent vraiment les sites : 146 cookies sur 269 sans SameSite
On sait ce que SameSite devrait faire. Restait à voir ce que les serveurs envoient. Le 6 octobre 2026, on a relevé une fois la page d'accueil de 704 organisations : les entreprises du SBF 120, 65 universités, 488 communes de plus de 20 000 habitants et 32 CHU ou CHR. Sur les 655 pages obtenues, 185 posent au moins un cookie dans les en-têtes Set-Cookie de la réponse, soit 269 cookies au total. Seuls ces cookies-là sont vus : ceux qu'un script pose ensuite, ou qui n'arrivent qu'après consentement ou connexion, échappent à la mesure.
Cadre de la mesure
| Catégorie | Valeur (cookies) |
|---|---|
| Absent | 146 |
| Lax | 74 |
| None | 30 |
| Strict | 19 |
Plus d'un cookie sur deux arrive sans attribut et s'en remet au comportement du navigateur, donc à Lax chez Chrome et Edge et à rien du tout ailleurs. Les 30 cookies en None sont tous marqués Secure, comme l'exigent les navigateurs : aucune faute de syntaxe de ce côté. Au niveau des pages, 43 sur 185 (23,2 %) posent des cookies qui portent tous à la fois Secure, HttpOnly et SameSite.
Les cookies qui comptent le plus pour la CSRF sont les cookies de session applicative : PHPSESSID, JSESSIONID, ASP.NET_SessionId, sessions Drupal, Joomla, TYPO3 ou Laravel. On en compte 101. Les deux autres attributs y sont souvent absents, et leur absence pèse plus lourd ici qu'ailleurs, parce qu'un cookie de session volé vaut la session.
- cookies de session applicativesur 269 cookies posés par 185 pages
- 101
- sans HttpOnlylisibles par n'importe quel script de la page, donc par une XSS
- 25/101
- sans Secure, sur une page en HTTPSenvoyés aussi sur une connexion HTTP en clair
- 35/95
Le lien avec la CSRF est direct. Un cookie de session sans HttpOnly se lit par JavaScript, et la XSS qui le lit lit aussi le jeton CSRF de la page. Un cookie sans Secure peut être posé ou écrasé par un attaquant placé sur un réseau en clair, ce qui suffit à fragiliser un double-submit naïf. Les trois attributs ne remplacent pas le jeton, ils l'empêchent d'être contourné par le côté.
8 842 CVE de type CSRF, 6 au catalogue des failles exploitées
Côté vulnérabilités publiées, la CSRF est tout sauf rare. On a recompté la NVD (instantané du 5 octobre 2026) : depuis 2015, 8 842 CVE portent la faiblesse CWE-352, soit 3,1 % des CVE dotées d'une CWE. Une grosse part vise l'écosystème WordPress : les CSRF représentent 12,3 % des CVE d'extensions WordPress, et au moins 72,4 % des CSRF publiées en 2023 visaient une extension. C'est le formulaire d'administration d'une extension qui oublie de vérifier le nonce WordPress, un défaut détaillé dans notre guide pour sécuriser un site WordPress.
À l'autre bout, le catalogue KEV de la CISA, qui recense les failles dont l'exploitation est confirmée, ne compte que 6 CSRF sur 1 734 entrées. Et sur 8 838 CVE CSRF dotées d'un score EPSS (probabilité estimée d'exploitation dans les 30 jours), 43 seulement atteignent 0,1. Il serait tentant d'en conclure que la CSRF ne s'exploite pas. Ce serait mal lire le KEV : il recense des produits. La CSRF du back-office développé en interne, celle qu'un pentester trouve dans une application métier, n'y entrera jamais, faute de CVE. C'est précisément celle-là que le jeton protège.
Quand le jeton CSRF ne protège pas
La protection anti-CSRF n'est pas absolue, et un pentester passe son temps à en chercher les bords. Trois situations où le jeton, ou SameSite, tombe.
- Un sous-domaine compromis : les cookies sont partagés entre sous-domaines d'un même domaine enregistrable. Si
blog.example.comest pris par un attaquant, il se retrouve dans le même contexte "same-site" queapp.example.comet contourne SameSite. Un subdomain takeover transforme alors un sous-domaine oublié en tremplin CSRF. La variante double-submit cookie naïve y est particulièrement fragile, car le sous-domaine peut écrire le cookie qui sert de référence. - Une XSS déjà présente : si l'attaquant exécute du JavaScript dans l'origine de la victime, il lit le jeton dans le DOM et l'ajoute à sa requête. Une faille de type Cross-Site Scripting annule par construction toute protection CSRF, ce qui explique pourquoi les deux figurent au OWASP Top 10 et se traitent ensemble.
- Une exemption trop large : chaque framework permet de désactiver la vérification sur certaines routes (webhooks Stripe, API machine à machine). Une exemption posée au niveau d'un préfixe entier, ou un
csrf_exemptlaissé sur un endpoint qui a fini par changer d'état, rouvre le trou en silence.
Le double-submit signé, pas le double-submit naïf
Jeton CSRF en Symfony, Django et Laravel
Aucun de ces frameworks ne vous demande d'écrire la vérification à la main : elle est activée par défaut sur les formulaires. Le piège est de la désactiver sans le vouloir. Voici la syntaxe actuelle des trois, vérifiée dans leur documentation courante.
Symfony
En Symfony 8, on génère le jeton en Twig avec csrf_token() et un identifiant arbitraire, puis on le valide dans le contrôleur avec isCsrfTokenValid() ou l'attribut #[IsCsrfTokenValid]. Les formulaires du composant Form l'intègrent seuls.
{# template Twig #}
<form action="{{ url('post_delete', { id: post.id }) }}" method="post">
<input type="hidden" name="token" value="{{ csrf_token('delete-item') }}">
<button>Supprimer</button>
</form>// controleur Symfony 8
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
#[IsCsrfTokenValid('delete-item', tokenKey: 'token')]
public function delete(Post $post): Response
{
// le jeton est verifie AVANT d'entrer ici ; sinon, 403
$this->repository->remove($post);
return $this->redirectToRoute('post_index');
}Django
Django protège via CsrfViewMiddleware, actif par défaut. Dans le gabarit, la balise {% csrf_token %} insère un champ caché csrfmiddlewaretoken. Le middleware compare ce champ (ou l'en-tête X-CSRFToken en AJAX) au secret du cookie, et vérifie l'Origin contre CSRF_TRUSTED_ORIGINS. Le décorateur @csrf_exempt est justement ce qu'on cherche à l'audit.
<!-- gabarit Django 5 -->
<form method="post">
{% csrf_token %}
<input type="email" name="email">
<button>Mettre a jour</button>
</form># a chasser en revue : une exemption laissee sur une vue qui change d'etat
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # <-- desactive toute la protection CSRF
def update_email(request):
request.user.email = request.POST["email"]
request.user.save()
return HttpResponse("ok")Laravel
En Laravel 12, la directive Blade @csrf pose le champ caché _token, et le middleware ValidateCsrfToken (dans le groupe web) le compare au jeton de session. Les exclusions se déclarent dans bootstrap/app.php via validateCsrfTokens(except: [...]), en général pour des webhooks. Une glob trop large ici, et des routes légitimes tombent sans filet.
{{-- vue Blade Laravel 12 --}}
<form method="POST" action="/profile">
@csrf
<input type="email" name="email">
<button>Enregistrer</button>
</form>
// bootstrap/app.php : n'exempter QUE ce qui doit l'etre
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'stripe/*', // webhook signe par Stripe, pas de cookie
]);
})Point commun aux trois : la protection est là par défaut, et les incidents naissent presque toujours d'une désactivation. C'est le genre de motif qu'une revue de code de sécurité ou une passe d'analyse statique appliquée à la sécurité remonte : une route qui écrit en base derrière un csrf_exempt, un verbe GET qui supprime, une exemption par glob trop gourmande.
Reproduire une CSRF sur une application de démo
Pour manipuler une CSRF sans viser personne, il faut une cible qu'on possède. OWASP Juice Shop est l'application volontairement vulnérable de référence, et elle apprend d'emblée une nuance utile : elle authentifie par un jeton JWT placé dans un en-tête Authorization: Bearer que son propre JavaScript ajoute. Le navigateur n'ajoutant jamais cet en-tête de lui-même sur une requête inter-sites, Juice Shop est immune à la CSRF classique par construction. C'est la meilleure démonstration de la règle : la CSRF ne vise que les authentifications par cookie automatique.
Pour voir l'attaque aboutir, on prend donc une application de démo authentifiée par cookie de session, sans jeton (par exemple un lab monté à cet effet, ou une instance Juice Shop reconfigurée en session cookie). La méthode de test tient en deux temps. D'abord, on capture une requête sensible avec un proxy comme Burp et on la rejoue sans le jeton ni l'Origin. Si le serveur répond 200, la route est forgeable.
# Requete forgee, capturee au proxy : ni jeton, ni en-tete Origin
POST /profile/email HTTP/1.1
Host: demo.local
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJ1c2VyIjo0Mn0...
Content-Length: 27
email=attaquant%40evil.testEnsuite, on matérialise l'exploitation avec la page HTML auto-soumise vue plus haut, ouverte dans un navigateur où l'on est connecté à la démo. Le cookie session part tout seul, l'e-mail est changé, et la preuve tient dans la requête sortante : pas de jeton, un Referer pointant vers le domaine de l'attaquant, et un serveur qui a quand même traité l'action. Ajoutez le jeton CSRF dans le formulaire de la démo, relancez : la requête forgée repart en 403. C'est cette bascule qu'on cherche à démontrer à un client, et c'est le genre de test qu'on mène en pentest web blackbox.
"Jeton CSRF invalide" : si vous êtes un utilisateur bloqué
Si vous avez atterri ici parce qu'un site vous répond "jeton CSRF invalide" (ou "CSRF token mismatch", "erreur 419") au moment de valider un formulaire, vous n'êtes pas attaqué et vous n'avez rien cassé. Ce message est une sécurité qui se déclenche quand le serveur ne retrouve plus le jeton attendu : le plus souvent, votre session a expiré parce que la page est restée ouverte trop longtemps avant l'envoi. Les causes typiques :
- Page laissée ouverte trop longtemps : le jeton et la session ont expiré entre l'affichage du formulaire et son envoi. Rechargez la page pour en obtenir un frais, puis renvoyez.
- Cookies bloqués : sans cookie de session, le serveur n'a aucun jeton auquel comparer. Autorisez les cookies du site, désactivez le mode privé strict ou l'extension qui les filtre, puis réessayez.
- Retour arrière du navigateur : revenir sur une page en cache réaffiche un ancien jeton, déjà consommé. Rechargez plutôt que d'utiliser le bouton précédent.
- Horloge de la machine déréglée : une date système fausse fait expirer cookies et jetons prématurément. Remettez l'heure à l'heure.
Dans la quasi-totalité des cas, recharger la page puis resoumettre suffit. Si le message persiste sur tous les sites, videz les cookies de votre navigateur. Ce n'est pas un signe d'intrusion : c'est la protection anti-CSRF qui fait son travail.
À retenir
- Une attaque CSRF force le navigateur d'une victime à émettre une requête qui change un état, en abusant du cookie de session envoyé automatiquement. L'attaquant ne lit jamais la réponse, il déclenche l'effet de bord.
- Le jeton CSRF (synchronizer token) casse la prévisibilité de la requête : valeur secrète par session, dans le formulaire ou un en-tête, jamais en
GET. C'est la défense principale. - SameSite=Lax par défaut depuis Chrome 80 bloque le POST forgé mais laisse passer les
GETde premier niveau, ne couvre pas un sous-domaine compromis, et n'existe ni dans Firefox ni dans Safari. C'est un complément, pas un remplaçant. - Mesuré : 146 des 269 cookies posés par 185 pages d'accueil n'ont pas d'attribut SameSite, et sur 101 cookies de session, 25 n'ont pas
HttpOnlyet 35 sur 95 pasSecure. Posez les trois attributs explicitement. - Le jeton ne protège pas contre une XSS présente, un subdomain takeover, ou une exemption trop large ; préférez le double-submit signé en HMAC au double-submit naïf.
- En Symfony, Django et Laravel, la protection est active par défaut : les incidents naissent d'un
csrf_exemptou d'une exemption par glob oubliée. C'est ce que traque une revue de code. - Le message "jeton CSRF invalide" côté utilisateur n'est pas une attaque : recharger la page et vérifier les cookies suffit presque toujours.
La CSRF est une faille de configuration autant que de code : elle se glisse dans l'endpoint qu'on a exempté "juste pour le webhook" et qui a fini par changer d'état. La repérer, c'est confronter chaque route qui écrit à sa protection réelle. C'est ce que fait l'analyse statique SecAI au fil des commits, et ce qu'on vérifie à la main sur une application entière en pentest web. Un doute sur un endpoint précis ? Parlez-en via la page contact.
Questions fréquentes sur le jeton CSRF
Qu'est-ce qu'un jeton CSRF ?
C'est une valeur secrète et imprévisible que le serveur génère par session et exige sur chaque requête qui modifie un état (POST, PUT, DELETE). Il est déposé dans le formulaire ou un en-tête, jamais dans un simple cookie exploitable. Un site tiers ne peut pas deviner cette valeur, donc une requête forgée depuis un autre domaine arrive sans le bon jeton et se fait rejeter par le serveur.
Le jeton CSRF est-il encore utile avec SameSite par défaut ?
Oui. Depuis Chrome 80 en 2020, Chrome et Edge traitent un cookie sans attribut SameSite comme Lax, ce qui bloque la plupart des POST inter-sites. Mais Firefox et Safari n'appliquent pas ce défaut, Lax laisse passer les navigations GET de premier niveau, et un sous-domaine compromis reste same-site. Or l'attribut manque souvent : sur 269 cookies posés par 185 pages d'accueil mesurées le 6 octobre 2026, 146 n'en ont aucun. Le jeton reste la défense principale, SameSite venant en complément.
La CSRF est-elle une faille fréquente ?
Fréquente dans les CVE, rare dans les failles exploitées recensées. Depuis 2015, la NVD compte 8 842 CVE de type CSRF (CWE-352), 3,1 % des CVE dotées d'une CWE, dont une large part sur des extensions WordPress : au moins 72,4 % des CSRF publiées en 2023. Le catalogue KEV de la CISA n'en compte que 6 sur 1 734 entrées. Ce catalogue recense des produits : une CSRF dans une application maison n'y entre jamais.
Pourquoi j'ai le message jeton CSRF invalide ?
Le plus souvent, votre session a expiré ou vos cookies sont bloqués : le serveur ne retrouve plus le jeton attendu. Le formulaire laissé ouvert trop longtemps, un retour arrière dans le navigateur, ou une extension qui filtre les cookies produisent le même effet. Rechargez la page pour obtenir un jeton frais, vérifiez que les cookies du site sont autorisés, puis renvoyez le formulaire.
Quelle différence entre XSS et CSRF ?
Une XSS injecte et exécute du code dans la page de la victime : l'attaquant lit et modifie tout, y compris le jeton CSRF. Une CSRF ne lit rien, elle force juste le navigateur de la victime à émettre une requête vers un site où elle est connectée. La CSRF exploite la confiance du serveur envers le cookie ; une XSS présente casse par définition toute protection CSRF.
Comment tester une vulnérabilité CSRF ?
Interceptez une requête qui change un état, retirez le jeton et l'en-tête Origin, puis rejouez-la : si le serveur l'accepte, l'endpoint est vulnérable. Construisez ensuite une page HTML qui soumet automatiquement le formulaire vers la cible et ouvrez-la dans un navigateur où vous êtes connecté. Cela ne se fait que sur une application que vous possédez ou dans le périmètre d'un pentest autorisé.
Une API REST avec jeton Bearer a-t-elle besoin de protection CSRF ?
Non, si l'authentification passe uniquement par un en-tête Authorization: Bearer que le code JavaScript ajoute lui-même. Le navigateur n'ajoute pas cet en-tête automatiquement sur une requête inter-sites, donc une page tierce ne peut pas l'usurper. La CSRF classique ne vise que les authentifications par cookie envoyé automatiquement. Une API qui s'appuie sur un cookie de session reste, elle, concernée.
Veille sécurité
La suite, une fois par mois
Ce qui bouge vraiment sur la surface d'attaque externe, NIS2 et la sécurité applicative, écrit par le pentester qui signe ces articles. Un envoi par mois, désinscription en un clic.
Votre adresse ne sert qu'à cet envoi. Voir la politique de confidentialité.
Dans le même dossier
appsec
DAST : ce que cinq scanners prouvent vraiment sur une même application
Le DAST attaque une application qui tourne, sans lire son code. Cinq scanners en sept configurations, notés par l'application : 12 failles prouvées sur 27 au mieux.
appsec
Pentest automatisé : ce que les outils prouvent vraiment, sur deux de nos bancs
Deux bancs, même règle pour tous, notre moteur compris : Juice Shop en septembre, puis une application qui note elle-même les exploitations, où notre moteur finit derrière ZAP et Wapiti.
appsec
DevSecOps : intégrer SAST et DAST dans sa CI/CD, et le bruit mesuré d'un SAST
Où placer le SAST et le DAST, brancher SARIF sur GitHub et GitLab, et régler un security gate. Mesuré sur 2 740 cas : même le meilleur SAST signale 38 % des cas sains, et le pack de règles pèse 18,2 points.