Comment fonctionne cette vérification
Scanaris lit le JavaScript de ta page d'accueil, y compris les scripts qu'elle charge depuis ton domaine, et cherche des appels à localStorage.setItem ou sessionStorage.setItem dont la clé évoque un identifiant : token, jwt, auth, secret, password, api_key, access, refresh ou bearer. S'il en trouve un, il ouvre un constat de sévérité faible. Faible, parce que stocker un jeton dans le navigateur n'est pas une brèche en soi ; ça en devient une combiné à n'importe quelle injection de script, et c'est pour ça que ça vaut la peine de le savoir.
Pourquoi c'est important
Tout ce qui est dans localStorage peut être lu par n'importe quel code qui s'exécute sur ta page : tes scripts, ceux des tiers que tu charges (analytique, chats, widgets) et tout script qu'un attaquant parvient à injecter. Une seule vulnérabilité XSS, dans ton code ou dans une dépendance, suffit pour que localStorage.getItem('token') parte vers un serveur étranger et que quelqu'un utilise la session de ton utilisateur depuis ailleurs, sans son mot de passe et sans qu'il s'en aperçoive. Un cookie avec HttpOnly est invisible pour JavaScript, donc ce même XSS ne peut pas le voler ; il peut faire des requêtes tant que la page est ouverte, ce qui est mauvais, mais il ne peut pas emporter la session.
D'où ça vient généralement
De la configuration par défaut de beaucoup de bibliothèques d'authentification dans les applications monopage. Le client Supabase pour le navigateur stocke la session dans localStorage si on ne lui dit pas autrement, comme beaucoup d'exemples de JWT « fait maison » avec Express. Lovable, Bolt et v0 génèrent ce motif parce que c'est celui de la documentation de démarrage rapide.
Comment corriger
- Session dans des cookies HttpOnly. Si tu as un serveur (Next.js, Nuxt, Express, une Edge Function), c'est lui qui reçoit le jeton du fournisseur et le stocke dans un cookie avec
HttpOnly,SecureetSameSite=Lax. Le navigateur l'envoie tout seul et ne le voit jamais. - Avec Supabase : en Next.js ou Nuxt, utilise
@supabase/ssr, qui stocke la session dans des cookies ; dans une SPA pure sans serveur, accepte le risque et compense-le avec une CSP stricte, parce qu'il n'y a pas d'autre endroit où la garder. - Des jetons courts et un rafraîchissement sûr. Si le jeton d'accès doit vivre en mémoire ou dans le storage, fais-le expirer en quelques minutes et mets celui de rafraîchissement dans un cookie
HttpOnly. - La mémoire plutôt que le storage. Garder le jeton dans une variable JavaScript (pas dans le storage) le protège des scripts qui lisent le storage, mais pas d'un XSS actif sur la même page. Mieux que localStorage, moins bien qu'un cookie.
- Réduis la surface de XSS. Une CSP sans
'unsafe-inline'dansscript-src, l'assainissement de tout HTML utilisateur et la revue des scripts tiers que tu charges. C'est la défense qui fait que le jeton dans le storage n'a plus d'importance.
Vérifie que c'est corrigé
curl -s https://tondomaine.com | grep -oE 'src="[^"]+\.js[^"]*"' | cut -d'"' -f2 | head -3
curl -s https://tondomaine.com/assets/index-XXXX.js | grep -oE "(local|session)Storage\.setItem\([^)]{0,60}" | head
Si la deuxième commande renvoie des lignes avec token, auth ou similaire dans la clé, le motif est toujours là. Dans les outils du navigateur, onglet Application, tu peux voir ce que ton appli stocke après la connexion.
Questions fréquentes
Supabase stocke la session dans localStorage par défaut. C'est mal ?
C'est le compromis qu'assume une SPA sans serveur. Si tu as un serveur, passe à @supabase/ssr. Sinon, soigne la CSP et les scripts tiers : ce jeton est aussi sûr que la page qui l'entoure.
sessionStorage est-il plus sûr que localStorage ?
Seulement en durée : il s'efface à la fermeture de l'onglet. N'importe quel script de la page le lit tout autant.
Pourquoi une sévérité faible si ça peut voler la session ?
Parce qu'il faut une autre vulnérabilité (un XSS) pour l'exploiter. La vérification te prévient pour que tu décides en connaissance de cause ; si ton site a déjà une CSP stricte et n'affiche aucun contenu utilisateur non assaini, le risque réel est faible.