Aller au contenu principal

Symfony 8.2 : des URLs signées à usage unique, sans stockage.

Symfony 8.2 ajoute l'usage unique aux URLs signées d'UriSigner via un jeton d'état replié dans le HMAC. Mécanisme, limites et comparaison avec login_link.

6 min de lecture
Sommaire · 6

Un lien de reset de mot de passe doit mourir au premier clic. Jusqu'ici, Symfony savait le faire expirer à l'heure près, pas le faire mourir à l'usage : il fallait un compteur quelque part. Symfony 8.2 ajoute l'usage unique aux URLs signées d'UriSigner, sans rien stocker côté serveur. On va voir le mécanisme exact (un jeton d'état replié dans la signature), le cas où la promesse tient, celui où elle ne tient pas, et ce que ça change par rapport aux login links et à un signer maison, celui de ce blog inclus.

Ce qu'UriSigner sait déjà faire

En 8.1, UriSigner signe une URL avec un HMAC et deux paramètres de query : _hash pour la signature, _expiration pour la date limite. La vérification est confortable depuis que verify() lève des exceptions typées (UnsignedUriException, UnverifiedSignedUriException, ExpiredSignedUriException), là où check() se contente d'un booléen.

Une URL signée, c'est une capability : le droit d'accès voyage dans le lien, pas dans une session. C'est d'ailleurs une des réponses classiques aux failles IDOR : au lieu d'un identifiant devinable que n'importe qui incrémente, un identifiant accompagné d'une preuve infalsifiable.

Sur le même sujet

Pour aller plus loin

La limite est connue : tant que la date n'est pas passée, le lien se rejoue à volonté. Transféré, copié dans un ticket de support, aspiré par un outil interne, il ouvre la porte autant de fois qu'on le présente.

Le paramètre version : un jeton d'état replié dans la signature

La PR #64408, signée Kevin Bond et mergée le 24 juin 2026 dans la branche 8.2, ajoute un argument $version à sign(), check(), checkRequest() et verify().

Le principe tient en trois temps. À la signature, la valeur de $version est concaténée à l'URI (séparateur \0) avant le calcul du HMAC-SHA256, puis jetée. Elle n'apparaît nulle part dans l'URL produite. À la vérification, on repasse la valeur courante : si elle a changé entre-temps, la signature ne colle plus, le lien est mort.

PHP
$url = $this->uriSigner->sign(
    $this->generateUrl('password_reset', ['id' => $user->getId()]),
    new \DateInterval('PT1H'),
    $user->getPassword(), // le jeton d'état
);

// au clic
$this->uriSigner->verify($request->getUri(), $user->getPassword());

Le hash du mot de passe comme jeton d'état : l'utilisateur clique, choisit un nouveau mot de passe, le hash change, et tous les liens de reset encore en circulation meurent d'un coup. Aucun nettoyage, aucune table used_tokens, aucune entrée de cache. L'invalidation est un effet mathématique.

Détail sorti de la revue de code : la proposition initiale exposait l'état dans un paramètre d'URL, et Nicolas Grekas a poussé pour le replier dans le hash, afin qu'un lien qui traîne ne fuite aucune information d'état. Détail de compatibilité, ensuite : sur la branche 8.2, les signatures PHP déclarées ne bougent pas, l'argument est lu via func_get_arg(). On le passe donc en positionnel, pas en argument nommé.

Là où la promesse tient, là où elle casse

La PR le dit sans détour : il suffit de passer une valeur qui change après le premier usage. Toute la subtilité loge dans ce « qui change ». Le lien de reset meurt parce que le clic aboutit à un changement de mot de passe. Le lien de vérification d'email meurt parce qu'un booléen isVerified bascule. Dans les deux cas, l'action portée par le lien modifie précisément l'état qui a servi à le signer.

Prenez maintenant un lien de téléchargement « à usage unique ». Télécharger un fichier ne change rien côté serveur. Aucun état ne bascule, la signature reste valide, le lien se rejoue jusqu'à expiration. Pour lui, il faudra toujours un compteur quelque part, donc du stockage.

Le lien ne se périme pas tout seul : c'est l'état qu'il modifie qui le périme. Si votre cas d'usage ne modifie rien, cette API ne vous donnera pas l'usage unique, seulement l'illusion confortable de l'avoir.

Symfony savait déjà fabriquer des liens qui meurent : les login links du composant Security. Leur recette est différente : max_uses s'appuie sur used_link_cache, un compteur dans un pool de cache. De l'état serveur, assumé.

Les deux approches se complètent. Le compteur garantit un décompte strict, même quand l'action ne change aucun état métier ; en échange, il faut héberger ce compteur et le faire expirer. Le jeton d'état ne coûte rien à héberger, mais il exige de trouver dans le métier une valeur qui bascule au premier usage.

À noter : les login links pratiquaient déjà une signature liée à l'état, via signature_properties. Un lien signé sur [id, email] meurt si l'email change. Le $version de 8.2 généralise cette idée à n'importe quelle URL, hors du périmètre security.

Chez moi : le token de confirmation qui se rejoue

Ce blog signe ses propres liens depuis un moment. Le NewsletterTokenSigner fabrique des tokens HMAC-SHA256 autoportants pour le double opt-in : un payload purpose + email + issuedAt, aucune donnée de contact stockée côté serveur, Mailjet est l'unique processeur. Le token de confirmation vit 24 heures, celui de désinscription dix ans.

Et pendant ces 24 heures, le lien de confirmation se rejoue autant qu'on veut. Rien de grave : reconfirmer un abonnement déjà confirmé est idempotent, l'enjeu est nul. Mais l'exercice de transposition est parlant. Pour rendre ce lien à usage unique avec l'API 8.2, il faudrait un jeton d'état, et le seul état disponible, le statut d'abonnement, vit chez Mailjet. La vérification coûterait donc un appel API tiers à chaque clic. Le « sans stockage » ne supprime pas l'état : il oblige à savoir où il habite. Ici, il habite chez quelqu'un d'autre.

La suite

Deux compléments de la même fournée 8.2. D'abord une dépréciation qui dit la philosophie : signer une URL sans expiration devient déprécié. Le lien éternel reste possible, mais il faudra l'écrire noir sur blanc. Ensuite une nouvelle contrainte de validation Cron, avec trois modes (MODE_STANDARD, MODE_ALIASES par défaut, MODE_HASHED pour les formes #daily du Scheduler), qui délègue l'analyse au package dragonmantank/cron-expression en dépendance optionnelle.

Côté calendrier : feature freeze en septembre 2026, sortie stable en novembre, maintenance jusqu'en juillet 2027 (version non-LTS), PHP 8.4 minimum. Le rythme se relit bien à la lumière du HTTP-less de Symfony 8.1 : la 8.x avance par retouches d'API ciblées plutôt que par grands soirs.

D'ici novembre, un inventaire s'impose : combien de liens « à usage unique » dans nos applications ne sont en réalité que des liens à durée limitée ? La réponse tient dans une seule question, à poser le code sous les yeux : qu'est-ce que le clic modifie ?

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Vous aimerez aussi

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.