Aller au contenu principal
Retour au blog

Sécurité auth Symfony 2026 : logout, CSRF, remember me et OIDC

Flavien Métivier19 septembre 20269 min

En juin 2026, une CVE discrète est passée sous le radar de la plupart des équipes Symfony : CVE-2026-45069, une faille dans OidcTokenHandler qui laissait passer des JWT cryptographiquement valides mais dépourvus des claims aud, iss ou exp — la vérification ne portait que sur les claims présents, pas sur leur présence elle-même. Au même moment, deux tickets ouverts sur symfony/symfony rappellent que l'authentification n'est jamais un sujet clos : la possibilité de désactiver la redirection au logout, et un allowedTimeDrift désormais configurable sur la validation des tokens OIDC. Entre ces évolutions et une pratique qu'on oublie trop souvent — la protection CSRF du logout —, la couche auth d'une application Symfony mérite une revue régulière, pas seulement un contrôle au moment du go-live. Cet article fait le point sur quatre fronts concrets : le logout (redirection et CSRF), le remember me, et la validation des tokens OIDC — en distinguant ce qui est stable en Symfony 8.1 aujourd'hui de ce qui arrive avec la 8.2, attendue en novembre 2026.

Logout : au-delà du bouton, une surface d'attaque négligée

Le logout dans Symfony est géré par le LogoutListener du composant security-http. Le comportement par défaut, stable depuis des années, tient en trois étapes : dispatch d'un LogoutEvent, invalidation du token via tokenStorage->setToken(null), puis — si aucun listener n'a défini de réponse sur l'événement — construction automatique d'une redirection HTTP vers la cible configurée (target, / par défaut). Ce fonctionnement part d'un principe implicite : une application Symfony sert toujours du HTML et un navigateur qui suit une redirection. Pour une API stateless consommée par une SPA ou une app mobile, ce principe ne tient plus, et beaucoup d'équipes bricolent des contournements maison pour éviter cette redirection parasite dans une réponse censée être du JSON.

La PR #46320, « Allow disabling redirect on logout », répond directement à ce besoin. Mergée le 9 juillet 2026 sur la branche de développement 8.2, elle permet de définir target: ~ (null) dans la configuration du firewall. Concrètement, quand target vaut null, le SecurityExtension n'enregistre plus le listener de redirection par défaut. Après dispatch du LogoutEvent, si aucun autre listener n'a appelé $event->setResponse(...), il ne se passe littéralement rien côté sécurité — la requête retombe sur le comportement normal du routeur pour la route de logout. Ce point mérite d'être souligné : au 19 septembre 2026, la version stable de Symfony reste la 8.1, et cette fonctionnalité n'existe que sur la branche 8.2 en développement, sortie prévue en novembre. Elle n'est documentée nulle part sur symfony.com/doc/current — seule la lecture du changelog et du diff GitHub le confirme. Ne l'utilisez pas en production tant que vous n'êtes pas passés sur 8.2 stable.

# config/packages/security.yaml
security:
    firewalls:
        main:
            lazy: true
            provider: app_user_provider

            logout:
                path: app_logout
                # target: ~ désactive la redirection (PR #46320, branche 8.2 — non stable au 19/09/2026)
                # Sur Symfony 8.1 stable, garder une cible explicite :
                target: app_login
                invalidate_session: true
                enable_csrf: true
                csrf_parameter: _csrf_token
                csrf_token_id: logout
                delete_cookies:
                    REMEMBERME: { path: /, domain: null }
                clear_site_data: ['cookies', 'storage']

Pour une API en 8.2, la bascule ne s'improvise pas : sans redirection automatique, c'est à votre code de fournir la réponse attendue par le client. Un listener sur LogoutEvent suffit, et il doit être posé avant de basculer target à null — sinon la requête de déconnexion finira en 404 ou 500 selon ce que le contrôleur de la route logout.path renvoie par défaut.

<?php
// src/EventListener/LogoutSubscriber.php
// Cas API : logout.target à null + réponse JSON explicite, sans redirection HTTP

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Http\Event\LogoutEvent;

#[AsEventListener(event: LogoutEvent::class)]
final class LogoutSubscriber
{
    public function __invoke(LogoutEvent $event): void
    {
        // Sans ce setResponse(), un target à null laisserait la requête
        // retomber sur le comportement par défaut de la route de logout,
        // souvent absent ou inadapté pour un client SPA/mobile.
        $event->setResponse(new JsonResponse(status: 204));
    }
}

CSRF sur le logout : la faille qu'on oublie

Le logout paraît anodin — après tout, il ne fait que déconnecter l'utilisateur. C'est justement ce qui en fait une cible sous-estimée : sans jeton CSRF, n'importe quel site tiers peut forcer la déconnexion d'une victime via un simple <img src="https://votre-app.fr/logout"> ou un formulaire auto-soumis. L'impact direct est un déni de service ciblé et gênant, mais c'est aussi un vecteur d'ingénierie sociale : un attaquant qui force une déconnexion peut ensuite pousser sa victime vers un faux écran de reconnexion, ou profiter d'un ré-login pour mener une attaque de fixation de session en parallèle. Ce n'est pas une faille théorique : c'est le genre de détail qui manque systématiquement dans les revues de code centrées sur le login et qui oublient le chemin inverse.

Sur Symfony 8.x, la protection tient en une seule clé : enable_csrf: true sous logout. Une précision utile pour qui a du code ancien sous les yeux : la clé historique csrf_token_generator, introduite en 2.1 sous le nom csrf_provider, a été dépréciée en 6.3 puis supprimée en 7.0. Si vous la croisez dans une configuration, c'est un signe de dette technique à corriger — enable_csrf: true suffit dans l'immense majorité des cas, en s'appuyant implicitement sur le service security.csrf.token_manager. Le LogoutListener vérifie alors nativement le jeton transmis en paramètre (csrf_parameter, _csrf_token par défaut) avant d'exécuter la déconnexion.

{# templates/_menu.html.twig #}
<a href="{{ path('app_logout', {_csrf_token: csrf_token('logout')}) }}">
    Se déconnecter
</a>

Le jeton est généré via csrf_token('logout'), l'identifiant logout correspondant à la clé csrf_token_id du firewall. C'est exactement le sujet traité par SymfonyCasts dans « Protecting Logout with CSRF », sixième épisode sur douze de la série « Symfony Security: The Basics », publié début juillet 2026 juste après l'épisode sur les liens de connexion/déconnexion et juste avant celui sur le remember me. La série a le mérite de traiter le logout comme un sujet de sécurité à part entière, et pas comme un détail d'UX qu'on code en cinq minutes en fin de sprint.

Remember me : cookie signé ou token en base, un choix qui compte

Le remember me a une particularité qu'on oublie souvent : il ne s'active pas automatiquement à la configuration YAML seule. Il faut que votre authenticator ajoute un RememberMeBadge au Passport retourné — sans ce badge explicite, la fonctionnalité reste inerte quelle que soit la configuration du firewall. C'est un changement salutaire par rapport à l'ancien système implicite de form_login : le développeur décide explicitement, dans le code, quels parcours d'authentification autorisent une session persistante.

Côté implémentation, Symfony propose deux gestionnaires très différents dans leurs garanties de sécurité :

Votre dette technique s'accumule ?

Demander un audit →
  • TokenBasedRememberMeHandler (par défaut, sans token_provider) — cookie signé cryptographiquement, sans stockage en base. Le hash inclut l'identifiant utilisateur, l'expiration et les signature_properties configurées (typiquement password). Stateless et simple, mais aucune révocation individuelle possible : seule une modification d'une propriété de signature invalide l'ensemble des tokens de l'utilisateur, et un vol de cookie ne se détecte jamais.
  • PersistentTokenBasedRememberMeHandler (activé via token_provider.doctrine: true) — stocke en base une paire series (identifiant stable de l'appareil) et value (jeton qui change à chaque requête authentifiée). La rotation permet de détecter un vol : si une série connue se présente avec une valeur qui ne correspond pas au dernier jeton généré, c'est le signal qu'un cookie volé a déjà été utilisé par un attaquant — le handler peut alors invalider toute la série et forcer une nouvelle authentification complète.

Pour une application qui manipule des données sensibles — facturation, données personnelles, accès à un espace client B2B —, le choix ne devrait pas être laissé au hasard : PersistentTokenBasedRememberMeHandler doit être la valeur par défaut, pas une option qu'on découvre après un incident. À cela s'ajoutent les réglages qu'on retrouve dans n'importe quelle checklist OWASP sur la gestion de session : secure: true obligatoire en production (cookie transmis uniquement en HTTPS), httponly: true pour bloquer tout accès JavaScript en cas de XSS, samesite: lax ou strict pour limiter l'exfiltration cross-site, et une lifetime raisonnable — le défaut d'un an mérite presque toujours d'être raccourci. Évitez également always_remember_me: true sur une application grand public ou accessible depuis un poste partagé : cela retire à l'utilisateur le contrôle explicite qu'offre la case à cocher.

# config/packages/security.yaml (suite)
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800              # 7 jours : le défaut Symfony (1 an) est souvent trop long
                name: REMEMBERME
                path: /
                secure: true                  # HTTPS obligatoire en production
                httponly: true
                samesite: lax
                always_remember_me: false      # ne jamais forcer, respecter le choix de l'utilisateur
                remember_me_parameter: _remember_me
                signature_properties: ['password']
                token_provider:
                    doctrine: true            # PersistentTokenBasedRememberMeHandler : rotation + détection de vol

Dernier réflexe indispensable : un utilisateur reconnecté uniquement via son cookie remember me ne doit jamais pouvoir déclencher une action sensible sans ré-authentification. Symfony distingue IS_AUTHENTICATED_REMEMBERED de IS_AUTHENTICATED_FULLY précisément pour ça — un changement de mot de passe, d'email ou un paiement doivent exiger le second, pas le premier.

<?php
// src/Controller/AccountController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class AccountController extends AbstractController
{
    #[Route('/account/password', name: 'app_change_password', methods: ['POST'])]
    public function changePassword(): Response
    {
        // Exclut explicitement une session restaurée via le seul cookie remember me
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');

        // ... logique de changement de mot de passe
        return $this->redirectToRoute('app_account');
    }
}

OidcTokenHandler et allowedTimeDrift : calibrer la tolérance d'horloge

Pour les applications qui délèguent l'authentification à un fournisseur d'identité externe via OIDC, la validation d'un token passe par trois vérifications temporelles : iat (émission), nbf (pas avant) et exp (expiration). Jusqu'ici, OidcTokenHandler transmettait à ces vérificateurs un allowedTimeDrift codé en dur à zéro seconde — aucune tolérance. Sur le papier c'est le comportement le plus strict, donc le plus sûr. En pratique, dans des environnements multi-serveurs ou conteneurisés sans synchronisation NTP rigoureuse, ce zéro absolu génère des rejets de tokens parfaitement légitimes, avec un message d'erreur du type « token émis dans le futur » qui n'a rien à voir avec une tentative d'attaque.

La PR #64632, mergée le 16 juin 2026 sur la branche 8.2, expose ce paramètre via une nouvelle clé allowed_time_drift, en secondes, avec toujours 0 comme valeur par défaut pour préserver la compatibilité ascendante. Comme pour la redirection du logout, cette fonctionnalité n'est pas stable au moment où j'écris ces lignes : elle vit sur la branche de développement, en attendant la sortie de la 8.2 en novembre. La littérature autour d'OIDC et des bibliothèques JWT converge généralement vers une tolérance de 30 à 60 secondes — suffisante pour absorber un léger décalage d'horloge entre votre application et le fournisseur d'identité, sans ouvrir de fenêtre significative pour un attaquant.

# config/packages/security.yaml (suite)
            access_token:
                token_handler:
                    oidc:
                        algorithms: ['RS256']
                        keyset: '%env(OIDC_JWKSET)%'
                        audience: 'my-api'                       # sans elle, la claim 'aud' n'est jamais vérifiée
                        issuers: ['https://idp.example.com/']    # sans eux, la claim 'iss' n'est jamais vérifiée
                        claim: sub
                        allowed_time_drift: 30    # secondes — tolérance NTP raisonnable, jamais plusieurs minutes

C'est justement l'articulation avec audience et issuers qui rend cette configuration plus délicate qu'un simple curseur de tolérance. La CVE-2026-45069 mentionnée en introduction touchait OidcTokenHandler sur les versions 6.3.0 à 6.4.39, 7.4.0 à 7.4.11 et 8.0.0 à 8.0.11 : la méthode de vérification des claims omettait de transmettre la liste des claims obligatoires au gestionnaire de vérification, qui ne valide que ce qui est présent dans le token. Un JWT signé correctement mais dépourvu des claims aud, iss ou exp passait alors la validation sans encombre. La leçon à en tirer dépasse largement ce correctif ponctuel : allowedTimeDrift n'est pas une case à cocher isolée, c'est un paramètre qui n'a de sens que si audience et issuers sont explicitement renseignés et si votre version de security-http est à jour. Une tolérance d'horloge généreuse combinée à une validation de claims incomplète transforme un simple confort d'exploitation en contournement d'authentification pur et simple.

Ce qu'il faut retenir pour durcir votre couche auth

Les quatre sujets abordés ici partagent un point commun : aucun n'est spectaculaire pris isolément, mais tous relèvent d'angles morts classiques de l'audit de sécurité applicatif. Un logout sans CSRF, un remember me sans rotation de token, une redirection non maîtrisée sur une API stateless, une validation OIDC mal calibrée — ce sont exactement les cas que couvre une checklist OWASP appliquée sérieusement, comme celle détaillée dans notre checklist sécurité Symfony en 30 minutes. La protection CSRF, plus largement, a beaucoup évolué avec les architectures stateless : nous l'avions détaillée dans notre analyse de Symfony 7.2, où la question du CSRF sur des API sans session se posait déjà en des termes très proches du logout traité ici. Et pour les équipes qui suivent déjà les évolutions HTTP-less du framework, notre article sur Symfony 8.1 donne le contexte plus large dans lequel s'inscrivent ces changements de la couche Security.

  • Activer enable_csrf: true sur le logout de chaque firewall, sans exception, y compris sur les firewalls historiques qu'on n'a pas retouchés depuis des années
  • Vérifier le handler remember me utilisé en production : passer à PersistentTokenBasedRememberMeHandler dès que l'application manipule des données sensibles
  • Distinguer IS_AUTHENTICATED_FULLY de IS_AUTHENTICATED_REMEMBERED sur toute action sensible, sans exception
  • Sur les intégrations OIDC, renseigner explicitement audience et issuers, et calibrer allowed_time_drift entre 30 et 60 secondes une fois la 8.2 disponible
  • Planifier la montée vers Symfony 8.2 en gardant un œil sur le changelog Security plutôt que d'attendre la note de version finale

Ce genre de revue — passer en détail chaque option de security.yaml, chaque badge d'authenticator, chaque gestionnaire de token — prend du temps et demande de connaître aussi bien le framework que les vecteurs d'attaque réels. C'est exactement ce que nous faisons chez The Bearded Bear avec Bear Scan : un audit de sécurité qui passe votre codebase Symfony au crible des risques OWASP, avec un scoring et une feuille de route priorisée, plutôt qu'une liste de bonnes intentions à appliquer un jour.

Cet article vous a plu ? Partagez-le !

Votre dette technique s'accumule ?

Audit complet en 10 jours. Recommandations priorisées et actionnables.