Login QR en PHP/Symfony : moderniser l'authentification sans sacrifier la sécurité
Le login par QR code, c'est cette fonctionnalité qu'on voit sur WhatsApp Web, Telegram ou Steam : vous scannez un code avec votre téléphone déjà connecté, et votre session navigateur s'ouvre automatiquement. Simple pour l'utilisateur, complexe à sécuriser côté backend.
Un article récent de Dmitry Isaenko (source originale sur dev.to) documente avec honnêteté l'extraction d'une telle feature depuis une application Laravel en production — et les 11 failles de sécurité corrigées avant de la merger. Ce retour d'expérience est précieux pour tout développeur PHP, y compris dans l'écosystème Symfony.
Comment fonctionne un flux QR login ?
Le principe repose sur trois endpoints et deux acteurs :
- Le navigateur (non authentifié) demande la génération d'un token et affiche le QR code correspondant.
- L'appareil mobile (déjà authentifié) scanne le QR et appelle un endpoint de vérification pour approuver la demande.
- Le navigateur interroge régulièrement le backend (polling) jusqu'à ce que la demande passe en état
approved, puis la session s'ouvre.
Trois endpoints : generate, verify, poll. Une mécanique en apparence simple, mais chaque étape cache des vecteurs d'attaque potentiels.
Les erreurs classiques que l'on retrouve en production
L'auteur identifie plusieurs catégories de problèmes dans sa version initiale. On les retrouve fréquemment dans des implémentations maison, que ce soit sous Laravel ou Symfony :
Gestion du token
- Token brut stocké en base : en cas de fuite de la base de données, n'importe qui peut usurper une session. La correction consiste à ne stocker qu'un hash (SHA-256 ou similaire), comme on le fait pour les mots de passe avec des reset tokens.
- Pas d'expiration stricte : un token qui ne meurt jamais est une porte ouverte permanente. Une TTL courte (1 à 2 minutes) est indispensable pour ce type de flux.
- Absence de liaison à un contexte : le token ne doit être utilisable que par l'appareil qui l'a scanné, idéalement en liant l'empreinte de l'appareil mobile à la demande lors de la vérification.
Contrôle d'accès et logique métier
- Endpoint de vérification accessible sans authentification : seul un appareil déjà connecté doit pouvoir approuver une demande. Sans ce contrôle, n'importe qui connaissant le token peut valider la session.
- Réutilisation du token : une fois approuvé, le token doit être invalidé immédiatement. Un token single-use est non négociable.
- Absence de rate limiting : le polling sans limite ouvre la voie à des attaques par force brute ou du scraping massif. Quelques requêtes par seconde maximum, par IP ou par token.
Surface d'exposition de l'API
- Réponses trop verbeuses : retourner l'état interne de la demande (identifiant utilisateur, timestamps internes, etc.) dans la réponse de polling expose des informations inutiles. La réponse doit se limiter à
pending,approvedouexpired. - Pas de validation de l'origine : vérifier que la requête de scan provient bien d'un client mobile attendu (via les headers, un secret partagé, ou un mécanisme de challenge) réduit la surface d'attaque.
Ce que cela implique concrètement en Symfony
Dans un projet Symfony, ces bonnes pratiques se traduisent naturellement :
// Génération du token : ne stocker que le hash
$rawToken = bin2hex(random_bytes(32));
$hashedToken = hash('sha256', $rawToken);
$qrRequest = new QrLoginRequest();
$qrRequest->setTokenHash($hashedToken);
$qrRequest->setExpiresAt(new \DateTimeImmutable('+90 seconds'));
$qrRequest->setStatus('pending');
$entityManager->persist($qrRequest);
$entityManager->flush();
// Retourner uniquement le token brut au navigateur (jamais le hash)
return $this->json(['token' => $rawToken]);
Pour le rate limiting, le composant symfony/rate-limiter est parfaitement adapté :
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
qr_poll:
policy: sliding_window
limit: 10
interval: '10 seconds'
Et pour l'invalidation immédiate après usage :
// Dans le contrôleur de polling, après approbation
$qrRequest->setStatus('consumed');
$entityManager->flush();
// Créer la session utilisateur, puis retourner la réponse
Le bon état d'esprit : code qui fonctionne ≠ code sécurisé
La leçon centrale de l'article source est peut-être la plus importante : un code qui tourne en production depuis des mois n'est pas forcément un code sûr. Il a simplement survécu sans être attaqué — ou sans que les attaques aient été détectées.
Extraire et réutiliser du code existant est une approche saine : la logique métier a été confrontée à des utilisateurs réels. Mais cette étape doit systématiquement être suivie d'un audit de sécurité, même sommaire, avant tout nouveau déploiement.
Pour une feature d'authentification, le minimum à vérifier :
- Les tokens sont-ils hashés en base ?
- Ont-ils une expiration courte et sont-ils invalidés après usage ?
- Les endpoints sont-ils correctement protégés par l'authentification ?
- Le rate limiting est-il en place ?
- Les réponses API n'exposent-elles que le strict nécessaire ?
Conclusion
Le login QR est un excellent exemple de fonctionnalité où le confort utilisateur et la sécurité semblent s'opposer, mais peuvent tout à fait coexister avec une implémentation rigoureuse. Les 11 corrections documentées par Dmitry Isaenko ne sont pas des cas exotiques : ce sont des erreurs courantes, que l'on retrouve dans des projets Laravel comme Symfony.
Chez MulerTech, nous intégrons systématiquement une revue de sécurité dans notre processus de développement, particulièrement pour tout ce qui touche à l'authentification et à la gestion de sessions. Ce type de retour d'expérience, concret et documenté, est exactement ce qui permet d'élever le niveau de qualité de l'ensemble de la communauté PHP.
📌 Article source : Extracting a QR login from a production app and closing 11 security holes before merge — Dmitry Isaenko, dev.to