WordPress
16 septembre 2026

Un reCAPTCHA invisible qui bloque la création de compte : quand un id HTML coûte des commandes

Le ticket disait simplement : impossible de créer un compte dans le tunnel de commande, message d’erreur « captcha invalide ».

Sur une page où aucun captcha n’est visible.

C’est le genre de symptôme qui fait perdre du temps parce qu’il envoie chercher au mauvais endroit. On soupçonne les clés d’API, le score de confiance, un blocage réseau, une extension de navigateur. La cause était bien plus bête, et elle tient dans un attribut HTML.

Le contexte

reCAPTCHA v3 fonctionne sans interaction. Aucune case à cocher, aucune image à identifier. Un script s’exécute, calcule un jeton, et l’écrit dans un champ caché du formulaire. Le serveur valide ensuite ce jeton auprès de Google.

Le champ caché est classiquement injecté en JavaScript dans un conteneur repéré par son identifiant :

<div id="captcha-box"></div>

Ça fonctionne parfaitement. Tant qu’il n’y a qu’un seul formulaire par page.

Le piège spécifique au tunnel PrestaShop

Sur la page de commande, PrestaShop rend deux formulaires client simultanément dans le même document. Ce n’est pas un accident d’intégration, c’est la structure livrée par le thème :

{* templates/checkout/_partials/steps/personal-information.tpl *}
<div class="tab-pane {if !$show_login_form}active{/if}" id="checkout-guest-form" role="tabpanel"
     {if $show_login_form}aria-hidden="true"{/if}>
  {render file='checkout/_partials/customer-form.tpl' ui=$register_form guest_allowed=$guest_allowed}
</div>
<div class="tab-pane {if $show_login_form}active{/if}" id="checkout-login-form" role="tabpanel"
     {if !$show_login_form}aria-hidden="true"{/if}>
  {render file='checkout/_partials/login-form.tpl' ui=$login_form}
</div>

Les deux panneaux sont rendus côte à côte, un seul portant la classe active. L’autre est masqué, pas absent : il est bien dans le DOM, avec tous ses champs, dès le chargement de la page.

Le hook du module de captcha, branché sur le rendu de formulaire client, s’exécute donc deux fois. Deux fois, il injecte son conteneur. Deux fois, il injecte son script.

Et deux fois, ce script fait la même chose :

document.getElementById('captcha-box').value = token;

getElementById retourne le premier élément portant cet identifiant. Les deux exécutions écrivent donc dans le même conteneur, celui du premier formulaire rendu.

Résultat : le formulaire invité reçoit bien son jeton. Le formulaire de création de compte, lui, a un champ vide. À la soumission, le serveur valide un jeton absent, et refuse. L’utilisateur voit un message parlant d’un captcha qu’il n’a jamais vu.

Un identifiant HTML doit être unique dans un document. C’est une règle que tout le monde connaît et que personne ne vérifie, parce que le navigateur ne proteste pas : il applique silencieusement la règle du premier arrivé.

Le diagnostic qui tranche

Inutile de deviner. Une ligne dans la console, sur la page de commande :

document.querySelectorAll('[id="captcha-box"]').length

Si le résultat est supérieur à 1, vous tenez votre cause. querySelectorAll avec un sélecteur d’attribut ne s’arrête pas au premier élément, contrairement à getElementById, ce qui en fait exactement l’outil pour prouver un doublon d’identifiant.

Le contrôle complémentaire consiste à inspecter la valeur du champ caché dans chacun des deux formulaires après chargement. Un rempli, un vide : le diagnostic est clos.

La contrainte : ne pas toucher au module

Le réflexe évident serait de corriger le template du module tiers. C’est aussi la garantie de perdre le correctif à la prochaine mise à jour, et de voir le bug revenir des mois plus tard sans que personne ne fasse le lien.

Le correctif est donc passé par une surcharge du template côté thème, avec deux changements.

Supprimer l’identifiant global, remplacé par une classe. Une classe peut légitimement être portée par plusieurs éléments, un identifiant non.

Scoper l’écriture au formulaire courant plutôt qu’au document entier :

// Le script s'exécute là où il est injecté : on remonte au formulaire
// qui le contient plutôt que de chercher dans tout le document.
const form = document.currentScript.closest('form');
form.querySelector('.captcha-token').value = token;

document.currentScript désigne l’élément <script> en cours d’exécution. Un closest('form') remonte au formulaire qui le contient. Chaque instance écrit alors dans son propre champ, quel que soit le nombre de formulaires présents.

Une réserve avant de la reprendre telle quelle : document.currentScript vaut null dans un script async, defer ou de type module, ainsi que dans tout rappel exécuté plus tard. Il n’est fiable que pendant l’exécution synchrone initiale. Si votre injection est différée, capturez la référence au chargement et conservez-la, au lieu de la relire au moment d’écrire le jeton.

Cette technique mérite d’être retenue au delà de ce cas précis. Dès qu’un composant peut être rendu plusieurs fois dans une page, tout ciblage DOM doit partir de sa propre position, jamais de la racine du document.

Le second garde-fou : l’expiration du jeton

Un jeton reCAPTCHA v3 a une durée de vie de deux minutes.

Sur une page de commande, deux minutes passent vite. Un utilisateur qui hésite sur son adresse, qui va chercher sa carte, qui est interrompu par un appel, soumet le formulaire avec un jeton périmé et se fait refuser exactement comme s’il était un robot.

Ce comportement était masqué par le premier bug. Une fois celui-ci corrigé, il restait une catégorie d’utilisateurs qui échouaient sans raison apparente, précisément les plus hésitants, c’est-à-dire souvent les premiers acheteurs.

Le correctif consiste à renouveler le jeton périodiquement, avant sa péremption, plutôt que de le calculer une seule fois au chargement :

// Le jeton expire à 120 s : on le rafraîchit à 90 s, marge comprise.
const champ = form.querySelector('.captcha-token');

function rafraichirJeton() {
    grecaptcha.execute(CLE_SITE, { action: 'checkout' })
        .then((token) => { champ.value = token; });
}

rafraichirJeton();
setInterval(rafraichirJeton, 90000);

Quatre-vingt-dix secondes plutôt que cent dix : la marge absorbe la latence de l’appel et le temps de soumission. Et le rafraîchissement porte sur champ, la référence capturée au chargement, jamais sur une nouvelle recherche dans le document.

C’est le genre de défaut qui ne remonte jamais par les canaux de support. Un client dont la création de compte échoue ne rédige pas un ticket : il s’en va. Le seul signal est une baisse de conversion que personne n’attribue jamais au captcha.

Trois enseignements réutilisables

Un module rendu plusieurs fois par page ne doit jamais dépendre d’un identifiant global. C’est vrai pour un captcha, mais tout autant pour un sélecteur de quantité, un bouton d’ajout au panier ou un formulaire de newsletter présent dans plusieurs zones. Le tunnel de commande PrestaShop, avec ses deux formulaires rendus simultanément, est le meilleur révélateur de ce défaut, et il mérite sa place dans la recette de mise en ligne.

Les protections anti-robots méritent une attention disproportionnée, parce qu’elles échouent dans le sens du refus. Un bug d’affichage se voit. Un bug qui bloque une inscription se traduit par un visiteur qui part sans rien dire.

Corrigez toujours à l’endroit qui survit à la mise à jour. Une surcharge dans le thème est plus longue à écrire qu’un patch dans le module, et c’est la seule qui sera encore là dans un an.

Nous vous recommandons aussi