Skip to content

CSRF — tokens, SameSite, double-submit, stateless APIs

TL;DR — CSRF (Cross-Site Request Forgery) exploite les cookies envoyés automatiquement par le navigateur. La parade côté Symfony : token CSRF (par défaut un secret stocké en session) inclus dans chaque formulaire, vérifié à la soumission. SameSite=Lax/Strict sur les cookies est une defense in depth. Sur API stateless (JWT en header Authorization), CSRF n'est pas nécessaire — mais reste obligatoire dès qu'on stocke des credentials dans un cookie.

Mental model — ASCII diagram + analogy

Analogie : Imagine un site bancaire avec cookie de session. Tu visites evil.com qui contient <img src="https://bank.com/transfer?to=hacker">. Ton navigateur joint automatiquement ton cookie bancaire → transfert exécuté. Le CSRF token est un mot de passe à usage unique présent dans le formulaire — evil.com ne peut pas le deviner.

   GET /transfer (form page)


   Server génère csrf_token('transfer_intention')


   HTML returned:
   <form>
     <input name="_token" value="abc123..."/>
     ...
   </form>


   POST /transfer  with  cookies + _token=abc123


   Server vérifie token via CsrfTokenManager

   ┌────┴────┐
   ▼         ▼
  OK        invalid → 403

Le secret n'est pas dans le cookie de session par défaut. Il est dans la session côté serveur. L'attaquant ne peut pas lire la session.

Code minimal — realistic snippet

Formulaire Symfony (CSRF activé par défaut)

php
<?php
namespace App\Form;

use App\Entity\Transfer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class TransferType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('amount', MoneyType::class)
            ->add('beneficiary');
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class'      => Transfer::class,
            'csrf_protection' => true,
            'csrf_field_name' => '_token',
            'csrf_token_id'   => 'transfer_form',     // intention name
        ]);
    }
}

Manual CSRF token dans Twig (pour AJAX / lien custom)

twig
<form action="{{ path('post_delete', { id: post.id }) }}" method="post">
    <input type="hidden" name="_token" value="{{ csrf_token('delete-post-' ~ post.id) }}">
    <button>Delete</button>
</form>
php
<?php
namespace App\Controller;

use App\Entity\Post;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;

final class PostDeleteController extends AbstractController
{
    // Symfony 7.1+ : l'id du token peut être une Expression évaluée par requête.
    // Ici on reconstruit 'delete-post-<id>' pour matcher le csrf_token() Twig per-item.
    #[Route('/post/{id}/delete', methods: ['POST'])]
    #[IsCsrfTokenValid(new Expression('"delete-post-" ~ args["post"].getId()'), tokenKey: '_token')]
    public function __invoke(Post $post, Request $request): Response
    {
        // L'attribute valide AVANT d'entrer ici (lève AccessDeniedHttpException -> 403 si KO).
        // Équivalent manuel, si vous n'utilisez pas l'attribute :
        // if (!$this->isCsrfTokenValid('delete-post-' . $post->getId(), $request->getPayload()->getString('_token'))) {
        //     throw $this->createAccessDeniedException();
        // }

        return $this->redirectToRoute('post_list');
    }
}

Piège classique : un csrf_token_id statique dans l'attribute ('delete-post') ne matchera jamais un token Twig généré par item ('delete-post-' ~ post.id). L'id doit être identique des deux côtés — d'où l'Expression ci-dessus (Symfony 7.1+). Avant 7.1, utilisez la validation manuelle isCsrfTokenValid() ou un id statique des deux côtés.

Cookies SameSite (defense in depth)

yaml
# config/packages/framework.yaml
framework:
    session:
        cookie_secure: true
        cookie_samesite: lax       # 'lax' (def), 'strict', 'none' (require secure)
        cookie_httponly: true
        gc_maxlifetime: 7200
  • Lax : cookies envoyés sur navigation top-level GET ; pas sur POST cross-site.
  • Strict : cookies envoyés uniquement sur même site (peut casser SSO).
  • None + Secure : explicite, indispensable pour cross-site iframes.
yaml
# config/packages/framework.yaml
framework:
    csrf_protection:
        stateless_token_ids:
            - submit
            - authenticate
            - logout

Avec un stateless_token_id, Symfony stocke le token dans un cookie chiffré au lieu de la session. Utile pour API + Hotwire / Turbo.

Patterns courants — 3–6 patterns

  1. Token par intention : csrf_token_id: 'delete-post'. Si on génère "global" partout, un attaquant qui exfiltre un token peut l'utiliser ailleurs.
  2. Token par item : ajouter l'ID 'delete-post-' ~ post.id pour empêcher un token valide pour le post 1 d'être réutilisé sur le post 2.
  3. SameSite=Lax + token CSRF : combinaison standard moderne. SameSite ne suffit pas seul (vieux navigateurs, sous-domaines).
  4. Double-submit cookie : pour API où la session n'existe pas, le client génère un token, l'envoie en cookie ET en header X-CSRF-Token. Le serveur compare. Pas besoin d'état serveur.
  5. stateless: true + JWT en Authorization header : pas de cookie → pas de CSRF.
  6. Rotation post-login : après authentification, invalider l'ancien token CSRF. Symfony gère ça via régénération de session.

Versions — Symfony 5.4 / 6.4 / 7.x

Topic5.46.47.x
csrf_protection activé par défautOKOKOK
is_csrf_token_valid Twig fnPrésentPrésentPrésent
IsCsrfTokenValid attributeAbsent6.2+OK
Stateless CSRF (cookie-based)Absent6.4+OK
csrf-token-manager service idOKOKOK
enable_csrf sur form_loginOKOKOK
Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorageOKOKOK
  • En 7.x, les stateless token providers ont mûri (cookie chiffré HMAC, sans session).
  • Les enums CsrfToken::class etc. sont stables.

Quand CSRF est obligatoire vs inutile

ScénarioCSRF utile ?
App Symfony classique avec session cookieOUI
SPA same-origin + cookie HttpOnly de sessionOUI
API REST avec JWT en header AuthorizationNON
API qui s'authentifie via header X-API-KeyNON
Webhook POST cross-origin (Stripe, GitHub)Non (signature HMAC à la place)
Login formOUI (sinon "login CSRF" possible)
Logout endpointOUI (sinon "logout CSRF" trolling)
Site public lecture seule (GET only)NON (mais soyez sûrs qu'aucun GET n'est mutatif)

Pitfalls — 5–8 concrete traps

  1. GET mutatif : GET /delete?id=42 est CSRF-able même avec token (un <img> n'envoie pas le token). Jamais de mutation en GET.
  2. csrf_protection: false global : désactiver par paresse → ouvre tout. Désactiver seulement sur endpoints stateless explicites.
  3. Token global réutilisé : csrf_token('a') accepté partout → mauvaise pratique. Token par intention.
  4. SameSite=Strict casse SSO : un user authentifié sur auth.example.com qui clique vers app.example.com ne porte pas son cookie. Tester en multi-domaine.
  5. stateless_token_ids + session active : conflit de stockage. Soit on est stateful, soit on est stateless, pas les deux sur le même token id.
  6. AJAX sans token : oubli de passer le token dans fetch() → 403 mystérieux en POST. Utiliser un meta tag global :
    html
    <meta name="csrf-token" content="{{ csrf_token('global') }}">
  7. Régénération de session non triggered post-login : risque de session fixation (proche de CSRF). Symfony régénère par défaut, vérifier qu'on ne désactive pas.
  8. cookie_secure: false en prod : cookie envoyé en clair sur HTTP → vol facile. Toujours true en production.
  9. Reverse proxy qui drop le cookie : Cloudflare / Varnish peut altérer SameSite — tester en bout de chaîne.

Testing — phpunit / functional

php
<?php
namespace App\Tests\Functional;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class CsrfTest extends WebTestCase
{
    public function testPostWithoutTokenIsRejected(): void
    {
        $client = static::createClient();
        $client->request('POST', '/post/1/delete');
        self::assertResponseStatusCodeSame(403);
    }

    public function testPostWithValidTokenSucceeds(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/post/1');

        $form = $crawler->selectButton('Delete')->form();
        $client->submit($form);
        self::assertResponseRedirects('/posts');
    }

    public function testApiBearerIsExemptFromCsrf(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/posts', server: [
            'HTTP_AUTHORIZATION' => 'Bearer ' . self::getJwt(),
            'CONTENT_TYPE'       => 'application/json',
        ], content: '{"title":"hello"}');

        self::assertResponseIsSuccessful();
    }
}

Crawler::selectButton('Delete')->form() récupère automatiquement le token caché du form.

🎬 Cas d'usage concrets

Scénario 1 — Formulaires sensibles dans un DMS de cabinet juridique

Le DMS du cabinet expose une centaine de formulaires HTML couvrant la création de dossier, l'upload de pièces confidentielles, le scellement d'un dossier (action irréversible) et la suppression définitive d'un document après délai légal. Tous ces formulaires utilisent Form Symfony qui injecte automatiquement un token CSRF _token par formulaire. Le scellement, particulièrement sensible, expose un formulaire ad hoc avec csrf_token_id dédié dossier_seal_{id} lié au dossier ciblé, ce qui empêche le rejeu d'un token volé sur un autre dossier. Les sessions sont configurées en cookie_samesite: strict et cookie_secure: true, durée 8 heures. Les uploads transitent par un endpoint POST classique avec token CSRF dans le multipart, jamais par un endpoint REST autonome. L'équipe valide la couverture CSRF via un test fonctionnel global qui scanne toutes les routes POST/PUT/DELETE et échoue si une route n'exige pas de token sans annotation #[NoCsrf] justifiée.

Scénario 2 — Checkout e-commerce avec gestion de double-submit

La marketplace e-commerce de mode protège son tunnel de checkout en trois étapes (adresse, paiement, validation) avec des tokens CSRF distincts par étape. La validation finale qui déclenche le paiement Stripe utilise un csrf_token_id checkout_pay_{cart_id} pour empêcher qu'un attaquant déclenche un paiement depuis un onglet zombie. Pour les utilisateurs revenant via le bouton retour navigateur, l'app détecte un token expiré (rotation à chaque commande validée) et propose un rechargement du formulaire avec un nouveau token plutôt que d'afficher une erreur. La SPA "shop minimaliste" en Stimulus utilise le pattern meta name="csrf-token" injecté côté serveur et un intercepteur axios ajoutant l'en-tête X-CSRF-Token. Les webhooks Stripe entrants sont protégés différemment (signature HMAC, pas CSRF) sur un endpoint #[NoCsrf] documenté.

Scénario 3 — Backoffice admin d'un SaaS comptable français

Le backoffice du SaaS comptable centralise des actions à très fort impact : invalidation d'un exercice clôturé, suppression d'un dossier client, export massif d'archives, changement d'IBAN bancaire de la société. Chaque formulaire admin utilise un token CSRF avec un csrf_token_id dérivé de l'identité de l'opérateur et de l'action (admin_iban_change_{tenant}). En complément, certaines actions ultra-sensibles imposent une réauthentification fraîche (step-up auth) via mot de passe ou 2FA dans les 5 minutes précédentes ; la validation du formulaire échoue si la fraîcheur n'est pas remplie. Les sessions admin sont cloisonnées sur un firewall séparé avec cookie distinct (admin_session) et timeout d'inactivité de 15 minutes. La revue trimestrielle de conformité valide que tous les écrans admin ont un test fonctionnel forçant un token expiré et vérifiant le rejet (403 ou 419).

🛠️ Exemple end-to-end

Use case : formulaire de scellement de dossier juridique avec token CSRF nommé par dossier et test fonctionnel de rejet sur token invalide.

php
<?php
// src/UI/Http/Form/SceauDossierType.php
declare(strict_types=1);

namespace App\UI\Http\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;

final class SceauDossierType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('motif', TextType::class, [
                'label' => 'Motif du scellement',
                'constraints' => [new NotBlank()],
            ])
            ->add('confirmer', SubmitType::class, ['label' => 'Sceller']);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setRequired('dossier_id');
        $resolver->setDefaults([
            'csrf_protection' => true,
            'csrf_field_name' => '_token',
            'csrf_token_id' => fn (array $options): string => 'dossier_seal_' . $options['dossier_id'],
        ]);
    }
}

// src/UI/Http/Controller/SceauController.php
namespace App\UI\Http\Controller;

use App\Application\Dossier\Command\SceauDossier;
use App\Domain\Dossier\Entity\Dossier;
use App\Security\Voter\DossierVoter;
use App\UI\Http\Form\SceauDossierType;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class SceauController extends AbstractController
{
    public function __construct(private readonly MessageBusInterface $bus) {}

    #[Route('/dossiers/{id}/sceller', methods: ['GET', 'POST'])]
    #[IsGranted(DossierVoter::ARCHIVE, subject: 'dossier')]
    public function sceller(#[MapEntity] Dossier $dossier, Request $request): Response
    {
        $form = $this->createForm(SceauDossierType::class, null, [
            'dossier_id' => (string) $dossier->getId(),
        ]);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $this->bus->dispatch(new SceauDossier($dossier->getId(), $form->getData()['motif']));
            return $this->redirectToRoute('dossier_show', ['id' => $dossier->getId()]);
        }

        return $this->render('dossier/sceller.html.twig', [
            'form' => $form->createView(),
            'dossier' => $dossier,
        ]);
    }
}

// tests/Functional/SceauCsrfTest.php
namespace App\Tests\Functional;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class SceauCsrfTest extends WebTestCase
{
    public function testFormulaireSansTokenEstRejete(): void
    {
        $client = static::createClient();
        $client->loginUser($this->associeAvecDossier());
        $client->request('POST', '/dossiers/' . $this->dossierId . '/sceller', [
            'sceau_dossier' => ['motif' => 'audit'], // _token absent
        ]);
        self::assertResponseStatusCodeSame(422);
    }
}

Quand utiliser / éviter

  • Toujours : sur formulaires HTML traditionnels avec session cookie.
  • Toujours : sur login/logout, même si l'endpoint vous semble "public".
  • Pas nécessaire : sur API stateless authentifiées par header.
  • À reconsidérer : si SameSite=Strict est implémenté ET tous les browsers cibles le supportent ET pas de fallback nécessaire — mais le coût d'ajouter un token est trivial.

Pour API stateless qui s'authentifie par cookie HttpOnly (et non JWT en header), CSRF reste pertinent. Le double-submit fonctionne ainsi :

1. GET /api/csrf-token
       Server: Set-Cookie: csrf=abc123; SameSite=Lax; Secure
                Response body: { "token": "abc123" }

2. POST /api/transfer
       Client envoie:
         Cookie:        csrf=abc123     (auto)
         X-CSRF-Token:  abc123          (manuel, JS lit son propre cookie)

3. Serveur compare cookie csrf == header X-CSRF-Token
       Si égal -> OK ; sinon -> 403

Pourquoi c'est sûr : evil.com ne peut pas lire le cookie csrf (Same-Origin Policy), donc ne peut pas forger le header X-CSRF-Token.

php
<?php
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

#[AsEventListener(event: 'kernel.request', priority: 50)]
final class DoubleSubmitCsrfListener
{
    public function __invoke(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return; // ne pas re-valider sur les sous-requêtes (forward, ESI)
        }

        $req = $event->getRequest();
        if (!in_array($req->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
            return;
        }
        if (!str_starts_with($req->getPathInfo(), '/api')) {
            return;
        }

        $cookieToken = $req->cookies->get('csrf');
        $headerToken = $req->headers->get('X-CSRF-Token');

        // hash_equals AVANT toute autre logique pour rester constant-time.
        // L'ordre des checks est important : on ne court-circuite pas sur la longueur.
        if (!is_string($cookieToken) || !is_string($headerToken)
            || !hash_equals($cookieToken, $headerToken)) {
            throw new AccessDeniedHttpException('CSRF token mismatch'); // 403, pas 400
        }
    }
}

Notes de production :

  • hash_equals() est obligatoire : === fuit la position du premier octet divergent par son temps d'exécution (timing attack). hash_equals compare en temps constant.
  • Le token cookie doit être non prévisible : bin2hex(random_bytes(32)), jamais uniqid() / mt_rand() (non cryptographiques).
  • Sous-domaines : si evil.attacker.example.com peut écrire un cookie sur .example.com, le double-submit naïf est contournable (cookie injection). Parade : préfixer le cookie avec __Host- (__Host-csrf), qui interdit l'attribut Domain et force Secure + Path=/. C'est la version durcie ("signed double-submit" / OWASP).
  • Le priority: 50 place ce listener après le routing (priorité 32) seulement si vous avez besoin des attributs de route ; ici on lit le path brut, donc une priorité haute (avant le firewall) est acceptable.

Tableau récapitulatif : quelle protection pour quel scénario

AppAuthCSRF strategy
Monolithe Symfony TwigSession cookieToken CSRF Symfony (form auto + manual)
SPA same-originSession cookieStateless CSRF cookie ou double-submit
SPA cross-originJWT in Authorization headerAucune (mais CORS strict)
Mobile appJWT in headerAucune
Webhook receiverHMAC signatureAucune (HMAC remplace CSRF)

Comment un staff engineer raisonne sur le CSRF

Le réflexe junior est « j'ajoute un token, c'est protégé ». Le réflexe staff est de modéliser la menace avant de choisir le mécanisme. La question fondatrice : est-ce que le navigateur attache automatiquement une credential que le serveur considère comme une preuve d'identité ? Si oui (cookie de session, Basic auth mémorisé, certificat client) → vous êtes CSRF-able et il faut un secret non-attaché-automatiquement (un token dans le body/header). Si non (JWT lu et posé manuellement en header Authorization par votre JS) → l'attaquant cross-site ne peut pas le reproduire, CSRF est hors-scope.

À partir de là, tout découle d'un seul invariant :

L'attaquant peut déclencher des requêtes vers votre origine, mais ne peut ni lire vos réponses ni lire/forger un secret lié à votre origine (Same-Origin Policy). La protection CSRF consiste à exiger, dans chaque mutation, une preuve que l'attaquant ne peut pas fabriquer.

Les trois familles de solutions ne sont que des manières différentes de matérialiser ce secret :

StratégieOù vit le secretÉtat serveur ?Quand le choisir
Synchronizer token (Symfony par défaut)Session serveurOui (session)Monolithe avec session. Le plus robuste.
Stateless / signed token (cookie HMAC)Cookie chiffré/signéNonApp sans session, Hotwire/Turbo, multi-instances sans session partagée.
Double-submitCookie + header (le JS recopie)NonAPI authentifiée par cookie où vous ne pouvez pas tenir d'état. Plus faible (cookie injection).

Threat model rapide (le tableau que je dessine au whiteboard)

VecteurBloqué par token ?Bloqué par SameSite ?Note
<form> auto-submit POST depuis evil.comOUILax/Strict OUILe cas d'école.
<img> / <link> GET cross-siteSeulement si pas de mutation en GETLax: non (top-level GET), mais GET ne doit pas muterNe jamais muter en GET.
fetch() cross-origin avec credentialsOUIOUI + CORS bloque la lectureLe préflight + CORS aident, mais ne remplacent pas le token.
Sous-domaine compromis écrivant un cookieOUI (synchronizer)NonDouble-submit naïf KO → __Host- prefix.
Login CSRF (forcer la victime à se logger dans le compte de l'attaquant)OUI (token sur le form de login)PartielSymfony : enable_csrf: true sur form_login.
BREACH-style leak du token via compression+reflectionNe pas refléter le token dans une réponse compressée contrôlable.

Pourquoi SameSite ne suffit pas (et n'est qu'une defense-in-depth)

  1. Couverture navigateur : SameSite=Lax est le défaut moderne, mais des clients anciens / webviews exotiques l'ignorent.
  2. Définition de « site » = eTLD+1, pas origin. a.example.com et b.example.com sont same-site. Un sous-domaine compromis contourne SameSite mais pas un token par-intention.
  3. Lax laisse passer les GET top-level → toute mutation en GET reste exploitable.
  4. Méthodes « safe » : un navigateur peut envoyer le cookie sur une navigation Lax ; ne dépendez pas de SameSite pour l'autorisation.

La position staff : token CSRF = contrôle primaire, SameSite = ceinture + bretelles, CORS strict = filet pour les API. On empile, on ne substitue pas.

Observabilité & exploitation en production

  • Métrique à exposer : taux de 403/419 CSRF par route. Un pic soudain = soit une attaque, soit (99% du temps) un déploiement qui a cassé l'injection du token (cache HTML d'un CDN qui sert un vieux token contre une nouvelle session).
  • Cache & CSRF s'opposent : ne jamais mettre en cache (CDN, Varnish, Cache-Control: public) une page contenant un token synchronizer — vous serviriez le même token à tout le monde, ou un token périmé. Marquez ces pages private, no-store.
  • Log structuré sur échec : route, token_id, referer, origin, has_cookie, has_token. Ne jamais logger la valeur du token (secret).
  • Faux positifs fréquents : session expirée → token orphelin → 419/403 sur un user légitime. UX recommandée : détecter, recharger le form avec un token frais (cf. scénario checkout) plutôt qu'afficher une erreur brute.

Failure modes — anatomie des pannes réelles

  1. Le 403 fantôme post-déploiement : CDN a mis en cache la page de formulaire. Tous les users reçoivent le même _token, valable seulement pour la première session. Symptôme : 403 massif et intermittent. Fix : no-store sur les pages avec form, ou passer en stateless CSRF (le token ne dépend plus de la session serveur).
  2. Le 403 en scale-out : 3 pods derrière un LB round-robin, sessions en mémoire (pas de Redis partagé). Le form est rendu par le pod A (token en session A), le POST tombe sur le pod B (session vide) → 403. Fix : session store partagé (Redis) OU stateless CSRF.
  3. Le double-POST Turbo : Turbo Drive recharge le form via fetch ; si le token est rendu une fois puis le DOM réutilisé, un token consommé peut être rejoué côté serveur en cas de rotation agressive. Fix : stateless_token_ids + ne pas rotater à chaque requête.
  4. Le mismatch d'id (vu plus haut) : Twig génère delete-post-42, le contrôleur valide delete-post. 403 systématique en prod, jamais détecté en tests si les tests soumettent le form complet (le crawler récupère le bon token mais l'id de validation diverge quand même). À tester explicitement.
  5. cookie_samesite: strict casse l'OAuth/SSO : le callback IdP → votre app est une navigation cross-site ; en Strict le cookie de session n'est pas envoyé, l'user « perd » sa session juste après le redirect. Fix : lax (suffisant car le callback est un GET top-level) ou un cookie de transition.

🏋️ Exercices

Exercice 1 — Token par-item de bout en bout (implement)

Objectif : protéger un endpoint POST /comment/{id}/delete avec un token CSRF par item validé via IsCsrfTokenValid (Expression), et prouver par test que le token du commentaire 1 est rejeté sur le commentaire 2. Indice/Solution : Twig csrf_token('delete-comment-' ~ id) ; attribute #[IsCsrfTokenValid(new Expression('"delete-comment-" ~ args["comment"].getId()'))]. Test : récupérer le form du commentaire 1, soumettre son _token sur l'URL /comment/2/delete (forger la requête à la main avec $client->request('POST', '/comment/2/delete', ['_token' => $token1])) → assert 403.

Exercice 2 — Double-submit durci avec __Host- (production-grade)

Objectif : implémenter un endpoint GET /api/csrf qui pose un cookie __Host-csrf (random_bytes(32), Secure, SameSite=Lax, Path=/) + un listener qui valide X-CSRF-Token en temps constant, et écrire un test vérifiant qu'un cookie sur un Domain parent est refusé. Indice/Solution : Cookie::create('__Host-csrf', $token)->withSecure(true)->withHttpOnly(false)->withSameSite('lax') (le préfixe __Host- interdit Domain et impose Path=/). Le JS doit pouvoir lire le cookie → httpOnly: false (c'est volontaire pour le double-submit). Listener : hash_equals. Test : injecter un cookie csrf sans préfixe et vérifier qu'il n'est pas accepté comme __Host-csrf.

Exercice 3 — Stateless CSRF pour app multi-pod (production-grade)

Objectif : configurer framework.csrf_protection.stateless_token_ids pour un form de paiement, déployer mentalement sur 3 pods sans Redis, et prouver que le form rendu par le pod A se valide sur le pod B. Indice/Solution : stateless_token_ids: [payment], csrf_token_id: 'payment'. Le token devient un cookie signé HMAC indépendant de la session → portable entre pods. Test : simuler en bootant deux kernels (ou en vérifiant que le CsrfTokenManager stateless ne lit pas la session via SessionTokenStorage).

Exercice 4 — Reproduire puis corriger le « 403 fantôme du CDN » (break-then-fix)

Objectif : construire un test qui simule un cache de la page de form (réutiliser un _token issu d'une première session sur une seconde session) et observer le 403 ; puis le corriger en passant le token id concerné en stateless. Indice/Solution : session A → récupérer _token. Nouvelle requête sans cookie de session A → POST avec l'ancien _token → 403 (synchronizer). Bascule en stateless_token_ids : le même token redevient valide car il n'est plus lié à la session. Conclusion à documenter : Cache-Control: private, no-store sur les pages avec synchronizer token.

Exercice 5 — Login CSRF (break-then-fix)

Objectif : démontrer qu'un form_login sans enable_csrf permet un login CSRF (forcer la victime à se connecter au compte de l'attaquant), puis le fermer. Indice/Solution : désactiver le CSRF du login, monter une page tierce qui auto-submit POST /login avec les creds de l'attaquant → la victime navigue désormais sous l'identité de l'attaquant (et l'attaquant lit l'historique injecté ensuite). Fix : form_login: { enable_csrf: true } + un csrf_token_id dédié authenticate. Test : POST /login sans _csrf_token → échec.

Exercice 6 — Timing oracle (break-then-fix, hardcore)

Objectif : remplacer hash_equals par === dans le listener double-submit, écrire un micro-bench qui mesure la différence de temps selon le préfixe commun, puis revenir à hash_equals et montrer que le signal disparaît. Indice/Solution : boucle de N=10^5 comparaisons avec hrtime(true) sur des tokens partageant 0 / 8 / 16 octets de préfixe. Avec ===, le temps croît avec le préfixe commun (early-exit). Avec hash_equals, plat. Discuter pourquoi en pratique le réseau noie le signal — mais que « difficile à exploiter » ≠ « sûr ».

🎤 En entretien

Q : Pourquoi un token CSRF protège-t-il, alors qu'un attaquant peut envoyer n'importe quelle requête ? R : Parce que la Same-Origin Policy empêche l'attaquant de lire une réponse de votre origine ou de lire/forger un secret lié à votre origine. Il peut déclencher la requête (avec vos cookies attachés automatiquement), mais pas connaître le token qui vit dans votre session/DOM. CSRF = « tu peux frapper à la porte mais tu n'as pas la clé secrète à présenter ».

Q : SameSite=Strict est activé. Le token CSRF devient-il inutile ? R : Non. « Site » = eTLD+1, donc un sous-domaine compromis reste same-site et contourne SameSite. La couverture navigateur n'est pas universelle (vieilles webviews), et Lax laisse passer les GET top-level. SameSite est une defense-in-depth, pas un remplacement. On empile : token (primaire) + SameSite + CORS.

Q : Une API REST stateless en JWT a-t-elle besoin de CSRF ? R : Non, si le JWT est lu par votre JS et posé manuellement dans Authorization: Bearer. Le navigateur ne l'attache pas automatiquement, donc un site tiers ne peut pas le reproduire. Mais si vous stockez le JWT dans un cookie (même HttpOnly), il redevient auto-attaché → CSRF revient, et il faut un token / double-submit / SameSite.

Q : Synchronizer token vs double-submit : lequel et pourquoi ? R : Synchronizer (secret en session) est plus robuste car le secret ne quitte jamais le serveur ; coût = état de session. Double-submit (cookie + header recopié) est stateless mais vulnérable au cookie injection depuis un sous-domaine — d'où la version durcie avec préfixe __Host- et/ou un token signé (HMAC du session-id). En multi-pod sans session partagée, je préfère le stateless signed token de Symfony plutôt qu'un double-submit maison.

Liens

Bibliothèque tech perso — Achref