Un test de connexion qui passe en local puis vire au rouge le jour où quelqu'un active le CAPTCHA en recette : la plupart des équipes QA ont déjà vécu ce scénario. Pour tester de bout en bout une page de connexion avec CAPTCHA, faites tourner la CI sur le mode test du fournisseur et réservez le vrai CAPTCHA à un job de préproduction et à une sonde de connexion en production à basse fréquence.
Ce mode test prend des formes variées : clés factices de Cloudflare et de Google, Testing Tokens de Clerk, émulateur Firebase Auth, IP AllowList d'Auth0. L'API CaptchaAI n'intervient qu'en préproduction et en sonde, à deux conditions : le widget est d'un type qu'elle résout (Turnstile et reCAPTCHA v2, v3 et Enterprise en disponibilité générale, ou GA ; Friendly Captcha et CaptchaFox en bêta), et le token atteint votre serveur par un champ ou un paramètre que votre test maîtrise. Pour plusieurs des sept solutions ci-dessous, la réponse honnête reste le mode test.
Périmètre : votre propre tenant, des comptes synthétiques, des tests autorisés
Périmètre. Ce guide s'adresse aux équipes qui testent ou surveillent la connexion et l'inscription de leur propre application, dans un tenant, un projet ou un realm qu'elles administrent, avec des comptes de test synthétiques dédiés : QA autorisée, CI et supervision de disponibilité. Il ne sert ni à se connecter à des comptes qui ne vous appartiennent pas, ni à ouvrir des comptes à la chaîne, ni à tester des identifiants sur un autre service.
Trois règles d'hygiène s'appliquent avant même le premier test :
- Marquez chaque compte synthétique : sous-adresse
+e2eou numéro de test réservé. - Supprimez ce que créent les tests d'inscription.
- Réservez la clé CaptchaAI aux jobs de préproduction et de sonde : la CI n'en a pas besoin.
Côté RGPD, des adresses fictives sur un domaine réservé tiennent les données réelles de vos clients hors des tests ; vos logs, eux, ne doivent conserver ni mot de passe ni token en clair.
Où tourne chaque test E2E de connexion, et ce qu'il coûte
Couper le CAPTCHA en CI est légitime, à condition de garder un job de préproduction avec le vrai widget : seule une vraie clé révèle une liste de domaines erronée, un en-tête CSP qui bloque le widget ou un secret jamais configuré.
| Environnement | Clés CAPTCHA | Appels CaptchaAI | Alerte sur |
|---|---|---|---|
| CI, à chaque push | Clés de test, Testing Tokens, émulateur, IP AllowList | Aucun | Échecs de test ordinaires |
| Préproduction, chaque nuit | Vraies clés sur un tenant ou un realm de préproduction | Quelques-uns par exécution | Erreurs de résolution et connexions refusées, séparément |
| Sonde de production | Vraies clés, compte synthétique dédié | Un par vérification, toutes les 15 à 30 minutes | Même séparation qu'en préproduction |
Ne fusionnez jamais ces deux alertes. Un code d'erreur CaptchaAI signifie que la vérification elle-même est cassée. Une résolution acceptée suivie d'une connexion refusée indique au contraire que de vrais utilisateurs risquent d'être bloqués eux aussi.
Côté budget, CaptchaAI facture au thread, avec des résolutions illimitées par thread, et non à la résolution. BASIC ($15/mois, 5 threads) couvre largement une sonde de production et une suite nocturne, puisque chaque vérification n'occupe un thread que le temps de sa résolution. Les offres à jour figurent sur la page des tarifs CaptchaAI.
Matrice des fournisseurs : CAPTCHA affiché, mode test et place de CaptchaAI
Pour chaque solution : le CAPTCHA affiché, la méthode CaptchaAI, le chemin de test officiel et l'usage qui reste une fois la CI réglée.
| Fournisseur | CAPTCHA affiché | Méthode CaptchaAI et statut | Chemin de test officiel | Intérêt restant de CaptchaAI |
|---|---|---|---|---|
| Auth0 Bot Detection | Auth Challenge (défaut), Simple CAPTCHA, reCAPTCHA Enterprise, hCaptcha, Friendly Captcha, Arkose | reCAPTCHA Enterprise : userrecaptcha + enterprise=1 (GA) ; Friendly Captcha (bêta) ; hCaptcha et Arkose non pris en charge |
« Require a CAPTCHA : Never » ou IP AllowList | Votre partial Turnstile |
| Auth.js Credentials | Votre widget, souvent Turnstile ou reCAPTCHA v2 | turnstile, userrecaptcha (GA) |
Clés de test de l'éditeur | E2E en préproduction, sonde |
| Better Auth | Turnstile, reCAPTCHA, hCaptcha, CaptchaFox ou Vercel BotID | Turnstile et reCAPTCHA en GA ; CaptchaFox (bêta) ; hCaptcha non pris en charge ; BotID n'est pas un CAPTCHA | Clés de test de l'éditeur | Test API en préproduction |
| Clerk | Turnstile, à l'inscription uniquement | Turnstile en GA, mais le SDK de Clerk contrôle le widget | Testing Tokens | Hors périmètre |
| Firebase Auth | reCAPTCHA v2 (téléphone), reCAPTCHA Enterprise (bot protection) | Types en GA, mais le SDK contrôle le vérificateur | Émulateur, numéros fictifs | Déconseillé |
| Supabase Auth | hCaptcha ou Turnstile | Turnstile en GA via captchaToken ; hCaptcha non pris en charge |
Secret de test dans config.toml |
Smoke test en préproduction (Turnstile) |
| Keycloak | reCAPTCHA v2, v3 ou Enterprise à l'inscription | userrecaptcha (GA), plus enterprise=1 pour Enterprise |
Clés de test v2 de Google | E2E d'inscription en préproduction |
En CI : clés de test des fournisseurs et Testing Tokens
La plupart des suites de connexion n'ont besoin d'aucune vraie résolution : les éditeurs publient des clés de test, et ce sont elles qui tournent à chaque push.
Cloudflare Turnstile : des clés factices qui vont par paires
Les clés factices de Cloudflare fonctionnent sur tout nom d'hôte, localhost compris, et vont par paires : un secret de test n'accepte que le token factice, un secret de production le rejette. Basculez toujours les deux ensemble.
| Clé Turnstile | Valeur | Comportement |
|---|---|---|
| Sitekey | 1x00000000000000000000AA |
Passe toujours |
| Sitekey | 2x00000000000000000000AB |
Échoue toujours |
| Sitekey | 3x00000000000000000000FF |
Force un défi interactif |
| Secret | 1x0000000000000000000000000000000AA |
Siteverify passe toujours |
| Secret | 2x0000000000000000000000000000000AA |
Siteverify échoue toujours |
Le sitekey « passe toujours » remplit cf-turnstile-response avec le token factice XXXX.DUMMY.TOKEN.XXXX : un test navigateur n'a qu'à attendre que ce champ ne soit plus vide. Les tests au niveau API peuvent envoyer directement cette chaîne, puisque le secret de test n'accepte rien d'autre.
reCAPTCHA et hCaptcha : les clés publiées par les éditeurs
- reCAPTCHA v2 : la paire de test de Google associe le sitekey
6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhIet le secret6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe. Le widget affiche un bandeau d'avertissement ; la case reste à cocher mais n'ouvre jamais de défi, et chaque vérification réussit. - reCAPTCHA v3 : Google ne publie pas de clé de test v3. Créez une clé v3 distincte, réservée aux environnements de test.
- hCaptcha : des clés de test existent aussi (sitekey
10000000-ffff-ffff-ffff-000000000001, secret0x0000000000000000000000000000000000000000). Elles comptent d'autant plus que CaptchaAI ne résout pas hCaptcha.
Un seul build pour la CI et la préproduction
Pilotez la bascule par l'environnement, pour qu'un même build serve aux deux jobs ; le tag @real-captcha isole les tests à vraie résolution :
jobs:
e2e-ci:
runs-on: ubuntu-latest
env:
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
RECAPTCHA_SITE_KEY: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
RECAPTCHA_SECRET_KEY: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
steps:
- uses: actions/checkout@v4
- run: npm ci && npx playwright install --with-deps chromium
- run: npx playwright test --grep-invert @real-captcha
e2e-staging:
runs-on: ubuntu-latest
environment: staging
env:
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
CAPTCHAAI_API_KEY: ${{ secrets.CAPTCHAAI_API_KEY }}
SYNTHETIC_USER_EMAIL: ${{ vars.SYNTHETIC_USER_EMAIL }}
SYNTHETIC_USER_PASSWORD: ${{ secrets.SYNTHETIC_USER_PASSWORD }}
steps:
- uses: actions/checkout@v4
- run: npm ci && npx playwright install --with-deps chromium
- run: npx playwright test --grep @real-captcha
Le vrai danger est l'inverse : un secret de test qui atterrit en production et laisse tout passer. Un garde-fou au démarrage l'écarte :
// Refuse to start production with a vendor test key anywhere in the environment.
const VENDOR_TEST_KEYS = [
"1x0000000000000000000000000000000AA", // Turnstile, always passes
"6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe", // reCAPTCHA v2 test secret
"0x0000000000000000000000000000000000000000", // hCaptcha test secret
];
if (process.env.NODE_ENV === "production") {
const leaked = Object.entries(process.env).filter(([, v]) => v && VENDOR_TEST_KEYS.includes(v));
if (leaked.length > 0) {
throw new Error(`test CAPTCHA keys in production: ${leaked.map(([k]) => k).join(", ")}`);
}
}
La démarche générale est détaillée dans notre guide sur la gestion des CAPTCHA en intégration continue.
Un helper Playwright commun pour les tests E2E avec vrai CAPTCHA
La préproduction et la sonde partagent un seul helper, qui procède ainsi :
- Il lit l'attribut
data-sitekeydu widget Turnstile ou reCAPTCHA v2 présent sur la page. - Il envoie
method=turnstileoumethod=userrecaptchaàhttps://ocr.captchaai.com/in.php, avecjson=1. - Il attend 15 secondes, puis interroge
res.phptoutes les 5 secondes, pendant 120 secondes au maximum.CAPCHA_NOT_READYsignifie que la résolution est en cours ; tout autrestatus: 0est une erreur. - Il dépose le token dans
cf-turnstile-response, dansg-recaptcha-responseou dans le nom de champ que vous lui passez.
// captchaai.ts: shared by the provider sections below.
import type { Page } from "@playwright/test";
type Task = { method: "turnstile"; sitekey: string } | { method: "userrecaptcha"; googlekey: string };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(path: string, params: Record<string, string>): Promise<{ status: number; request: string }> {
const res = await fetch(`https://ocr.captchaai.com/${path}?${new URLSearchParams({ ...params, json: "1" })}`);
const text = await res.text();
try {
return JSON.parse(text);
} catch {
throw new Error(`${path} returned non-JSON: ${text.slice(0, 80)}`); // e.g. a plain-text ERROR_ code
}
}
export async function solveWithCaptchaAI(task: Task & { pageurl: string }): Promise<string> {
const key = process.env.CAPTCHAAI_API_KEY;
if (!key) throw new Error("CAPTCHAAI_API_KEY is not set");
const submit = await call("in.php", { key, ...task });
if (submit.status !== 1) throw new Error(`in.php: ${submit.request}`);
const deadline = Date.now() + 120_000;
await sleep(15_000);
while (Date.now() < deadline) {
const poll = await call("res.php", { key, action: "get", id: String(submit.request) });
if (poll.status === 1) return poll.request;
if (poll.request !== "CAPCHA_NOT_READY") throw new Error(`res.php: ${poll.request}`);
await sleep(5_000);
}
throw new Error("no CaptchaAI result within 120 s");
}
export async function solveCaptchaOnPage(page: Page, field?: string): Promise<string> {
const widget = page.locator(".cf-turnstile[data-sitekey], .g-recaptcha[data-sitekey]").first();
const sitekey = await widget.getAttribute("data-sitekey", { timeout: 10_000 });
if (!sitekey) throw new Error("no Turnstile or reCAPTCHA v2 widget with data-sitekey on this page");
const turnstile = await widget.evaluate((el) => el.classList.contains("cf-turnstile"));
const pageurl = page.url(); // the page that hosts the widget, not the app that redirected here
const token = await solveWithCaptchaAI(
turnstile ? { method: "turnstile", sitekey, pageurl } : { method: "userrecaptcha", googlekey: sitekey, pageurl },
);
const name = field ?? (turnstile ? "cf-turnstile-response" : "g-recaptcha-response");
await page.evaluate(({ n, t }) => {
document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[name="${n}"]`).forEach((el) => { el.value = t; });
}, { n: name, t: token });
return token;
}
Fraîcheur des tokens et timeout Playwright
Appelez le helper une fois les autres champs remplis, puis soumettez aussitôt : un token Turnstile vit 300 secondes, un token reCAPTCHA deux minutes, et tous deux sont à usage unique. Turnstile se résout généralement en moins de 10 secondes, reCAPTCHA v2 en moins de 60. Le timeout par défaut de Playwright, 30 secondes par test, est plus court que le plafond de 120 secondes du helper : chaque test @real-captcha appelle donc test.setTimeout(180_000).
Le contrat submit/poll est détaillé dans notre guide pour résoudre Cloudflare Turnstile via l'API, et le guide des tests E2E avec Cypress le transpose à Cypress.
Trois limites du helper
- Le token doit partir avec le formulaire. Le helper ne fonctionne que si le serveur lit le token dans le formulaire posté. Une application monopage qui le garde dans l'état d'un composant, via le callback du widget, demande un test au niveau API (voir Better Auth et Supabase).
- Le reCAPTCHA v2 invisible (
data-size="invisible") exige en plusinvisible=1lors de l'envoi. - reCAPTCHA Enterprise demande
enterprise=1et renvoie le token dansresult, accompagné d'unuser_agentà réutiliser. Étendezcall()avant de viser un widget Enterprise.
Auth0 Bot Detection : une Action pre-user-registration qui vérifie Turnstile
La protection d'Auth0 se règle dans Dashboard > Security > Attack Protection > Bot Detection. L'option « Require a CAPTCHA » accepte Never, When Risky (avec un Bot Detection Level Low, Medium ou High) ou Always, et la liste des CAPTCHA Providers propose Auth Challenge (par défaut), Simple CAPTCHA, reCAPTCHA Enterprise, hCaptcha, Friendly Captcha et Arkose.
Ne résolvez pas le défi natif d'Auth0 dans vos tests
Dans un tenant de test, réglez Require a CAPTCHA sur Never, ou ajoutez les IP de sortie de vos runners à l'IP AllowList (jusqu'à 100 adresses ou plages CIDR). Cette option convient mieux aux runners auto-hébergés ou à IP de sortie fixe qu'aux runners hébergés par GitHub, dont l'IP change : un runner auto-hébergé sur une instance OVHcloud ou Scaleway à IP publique fixe s'y prête bien.
Auth0 affiche et vérifie lui-même Auth Challenge et Simple CAPTCHA, sans champ de token documenté. CaptchaAI ne résout ni hCaptcha ni Arkose, et Auth0 ne documente aucun moyen de passer à Universal Login un token reCAPTCHA Enterprise résolu ailleurs : restez sur Never ou sur l'AllowList.
Le cas favorable : votre propre widget Turnstile dans un partial
CaptchaAI trouve sa place avec un widget Turnstile que vous ajoutez vous-même. Les prompt partials, qui exigent un Custom Domain et un Custom Page Template, injectent du HTML dans les écrans d'inscription, par exemple au point d'insertion form-content-end. Tout champ dont le nom commence par ulp- arrive dans event.request.body de l'Action pre-user-registration, et l'attribut data-response-field-name de Turnstile renomme son champ caché en conséquence :
<!-- Partial: load https://challenges.cloudflare.com/turnstile/v0/api.js once from the page template -->
<div class="ulp-field">
<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-response-field-name="ulp-turnstile-token"></div>
</div>
L'Action vérifie le token auprès du siteverify de Cloudflare et refuse l'inscription en cas d'échec. Chaque tenant a sa paire : le sitekey factice et le secret « passe toujours » (secret d'Action TURNSTILE_SECRET) pour le tenant de test, les vraies clés en préproduction.
exports.onExecutePreUserRegistration = async (event, api) => {
const token = event.request.body?.["ulp-turnstile-token"];
if (!token) {
api.access.deny("turnstile_missing", "Please complete the verification and try again.");
return;
}
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: event.secrets.TURNSTILE_SECRET,
response: token,
remoteip: event.request.ip,
}),
});
const outcome = await res.json();
if (!outcome.success) {
const codes = (outcome["error-codes"] || []).join(",");
api.access.deny(`turnstile_failed:${codes}`, "Verification failed. Please try again.");
}
};
En préproduction, le test navigateur appelle solveCaptchaOnPage(page, "ulp-turnstile-token") juste avant de cliquer sur Continue. Un token absent apparaît alors dans les logs du tenant sous la forme turnstile_missing, et non comme un échec d'inscription obscur.
Pourquoi ne pas résoudre depuis l'Action, ou depuis une Server Action ? Les deux s'exécutent côté serveur, après l'envoi du formulaire : elles peuvent vérifier un token, jamais afficher ni résoudre un widget, et un token obtenu à ce stade arriverait trop tard. Un code qui résout son propre CAPTCHA désactive en outre la vérification pour tous les visiteurs. La résolution appartient au runner de test.
Auth.js (NextAuth) et le provider Credentials : vérifier le token dans authorize()
Auth.js v5 appelle authorize(credentials, request) avec les champs postés et la Request d'origine ; renvoyer null fait échouer la connexion. Un widget Turnstile placé dans votre formulaire de connexion personnalisé poste cf-turnstile-response en même temps que l'e-mail et le mot de passe, ce qui permet à authorize() de le vérifier en premier :
// auth.ts (next-auth v5)
import NextAuth, { CredentialsSignin } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { lookupUser } from "@/lib/users"; // your own: validates the unknown inputs, returns a User or null
class CaptchaFailed extends CredentialsSignin {
code = "captcha"; // the sign-in redirect carries ?code=captcha
}
async function turnstileOk(token: unknown, ip: string | null): Promise<boolean> {
if (typeof token !== "string" || token.length === 0) return false;
const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
if (ip) body.set("remoteip", ip);
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
const outcome = (await res.json()) as { success: boolean };
return outcome.success === true;
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { type: "email", label: "Email" },
password: { type: "password", label: "Password" },
"cf-turnstile-response": { type: "hidden" },
},
async authorize(credentials, request) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
if (!(await turnstileOk(credentials["cf-turnstile-response"], ip))) throw new CaptchaFailed();
return lookupUser(credentials.email, credentials.password); // null: wrong email or password
},
}),
],
});
Renvoyer null redirige avec code=credentials : un token rejeté ressemblerait alors trait pour trait à un mauvais mot de passe. La sous-classe de CredentialsSignin lui attribue son propre code, si bien qu'un test de préproduction peut distinguer code=captcha de code=credentials.
Le token étant un champ de formulaire ordinaire, le helper commun fonctionne tel quel ; pour les contrôles de nom d'hôte et d'action, voyez notre article sur la validation des tokens côté serveur.
Better Auth : le plugin captcha et l'en-tête x-captcha-response
Le plugin captcha de Better Auth vérifie les tokens sous forme de middleware. Son provider accepte cloudflare-turnstile, google-recaptcha, hcaptcha, captchafox ou vercel-botid. Par défaut, il protège /sign-up/email, /sign-in/email et /request-password-reset, et les clients envoient le token dans un en-tête x-captcha-response.
// auth.ts
import { betterAuth } from "better-auth";
import { captcha } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
captcha({
provider: "cloudflare-turnstile",
secretKey: process.env.TURNSTILE_SECRET_KEY!, // CI: 1x0000000000000000000000000000000AA
}),
],
});
Comme votre code client pose cet en-tête, la vérification de préproduction peut ignorer le widget et appeler l'endpoint sous le chemin de base par défaut /api/auth :
// better-auth-signin.spec.ts
import { test, expect } from "@playwright/test";
import { solveWithCaptchaAI } from "./captchaai";
test("sign-in accepts a real Turnstile token @real-captcha", async ({ request, baseURL }) => {
test.setTimeout(180_000);
const token = await solveWithCaptchaAI({
method: "turnstile",
sitekey: process.env.TURNSTILE_SITE_KEY!,
pageurl: `${baseURL}/sign-in`,
});
const res = await request.post("/api/auth/sign-in/email", {
headers: { "x-captcha-response": token, origin: baseURL! },
data: { email: process.env.SYNTHETIC_USER_EMAIL, password: process.env.SYNTHETIC_USER_PASSWORD },
});
expect(res.ok()).toBeTruthy();
});
Le reste dépend du fournisseur configuré :
- En CI, la même requête, avec l'en-tête réglé sur
XXXX.DUMMY.TOKEN.XXXXface au secret de test « passe toujours », valide le câblage du plugin sans aucune résolution. - Avec une clé
google-recaptchav2, envoyez plutôtmethod: "userrecaptcha"avecgooglekey. - CaptchaFox (bêta) exige votre propre proxy pour la résolution (l'usage de proxy est désactivé par défaut sur les comptes CaptchaAI : demandez au support de l'activer), ainsi que le user agent renvoyé, à réutiliser sur la requête.
- hCaptcha et BotID restent sur le mode test de leur éditeur : CaptchaAI ne résout pas hCaptcha, et BotID inspecte la requête elle-même plutôt qu'un token.
Clerk : protection à l'inscription, Testing Tokens et rôle limité de CaptchaAI
La Bot sign-up protection de Clerk (Protect > Rules dans le Dashboard) s'appuie sur Cloudflare Turnstile, ne s'applique qu'à l'inscription et ne présente un défi qu'aux clients qu'elle soupçonne d'être des bots, catégorie dans laquelle un navigateur scripté tombe facilement. Les flux personnalisés doivent afficher <div id="clerk-captcha" /> avant l'exécution de signUp.create(), faute de quoi Clerk se rabat sur un widget invisible qui bloque les bots suspectés. Une Server Action n'y change rien : elle tourne sur le serveur, alors que le widget tourne dans le navigateur.
La voie officielle, ce sont les Testing Tokens. Installez @clerk/testing, définissez CLERK_PUBLISHABLE_KEY et CLERK_SECRET_KEY, puis appelez clerkSetup() depuis un projet de setup dont dépendent vos projets de test ; un globalSetup défini comme fonction s'exécute dans un autre processus, et son environnement n'atteint jamais les workers.
// global.setup.ts
import { clerkSetup } from "@clerk/testing/playwright";
import { test as setup } from "@playwright/test";
setup.describe.configure({ mode: "serial" });
setup("global setup", async ({}) => {
await clerkSetup();
});
// sign-up.spec.ts
import { setupClerkTestingToken } from "@clerk/testing/playwright";
import { test } from "@playwright/test";
test("synthetic sign-up", async ({ page }) => {
await setupClerkTestingToken({ page });
await page.goto("/sign-up");
// Fill the fields your instance requires; use a +clerk_test address and code 424242.
});
Ce qu'il faut retenir des Testing Tokens :
- ils ont une durée de vie courte et sont liés à une seule instance ;
- ils fonctionnent en développement comme en production, mais en production les helpers ne gèrent pas la connexion par code : passez par e-mail et mot de passe ;
- les adresses dotées de la sous-adresse
+clerk_testse vérifient avec le code424242en mode test.
Peu de place, donc, pour CaptchaAI : la bot protection de Clerk couvre l'inscription, pas la connexion, et Clerk ne documente aucun moyen de passer à son SDK un token résolu ailleurs. Considérez CaptchaAI comme hors périmètre pour Clerk, sauf si votre essai en préproduction prouve le contraire.
Firebase Auth : RecaptchaVerifier, numéros de test et émulateur
Sur le web, la connexion par téléphone passe par RecaptchaVerifier, dont la signature v10+ est new RecaptchaVerifier(auth, containerOrId, parameters) ; l'option size: 'invisible' le rattache à votre bouton d'envoi. Deux couches peuvent s'y ajouter :
- la reCAPTCHA bot protection (reCAPTCHA Enterprise via Identity Platform) protège les flux e-mail et mot de passe comme les flux téléphone. En mode
ENFORCE, elle rejette les requêtes dépourvues de token reCAPTCHA ; en modeAUDIT, elle se contente de leur attribuer un score ; - la reCAPTCHA SMS defense ajoute aux flux SMS une évaluation du risque de toll fraud (fraude aux SMS surtaxés).
Le SDK garde la main sur le vérificateur : passez par les chemins de test de Firebase. Ajoutez jusqu'à 10 numéros fictifs avec des codes fixes sous Phone numbers for testing, puis activez soit appVerificationDisabledForTesting (un faux reCAPTCHA qui n'accepte que ces numéros), soit la connexion à l'émulateur Auth, qui n'exécute aucun reCAPTCHA :
// phone-sign-in.js: one test switch, off by default.
import { getAuth, connectAuthEmulator, RecaptchaVerifier, signInWithPhoneNumber } from "firebase/auth";
export function createPhoneSignIn(app, { testMode = "off" } = {}) {
const auth = getAuth(app);
if (testMode === "emulator") {
connectAuthEmulator(auth, "http://127.0.0.1:9099"); // the emulator does not run reCAPTCHA
} else if (testMode === "fictional-numbers") {
auth.settings.appVerificationDisabledForTesting = true; // set before the verifier renders
}
const verifier = new RecaptchaVerifier(auth, "sign-in-button", { size: "invisible" });
return (phoneNumber) => signInWithPhoneNumber(auth, phoneNumber, verifier);
}
Face à l'émulateur, le test récupère le code SMS via son API REST :
// phone-sign-in.spec.ts: runs against `firebase emulators:start --only auth`
import { test, expect } from "@playwright/test";
const PHONE = "+16505553434";
const CODES_URL = `http://127.0.0.1:9099/emulator/v1/projects/${process.env.FIREBASE_PROJECT_ID}/verificationCodes`;
test("phone sign-in against the Auth emulator", async ({ page }) => {
await page.goto("/login?auth=emulator"); // a test-only build maps this to testMode: "emulator"
await page.getByLabel("Phone number").fill(PHONE);
await page.getByRole("button", { name: "Send code" }).click();
let code = "";
await expect.poll(async () => {
const body = (await (await fetch(CODES_URL)).json()) as { verificationCodes?: { phoneNumber: string; code: string }[] };
code = body.verificationCodes?.filter((v) => v.phoneNumber === PHONE).at(-1)?.code ?? "";
return code;
}).not.toBe("");
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
});
Tenez les numéros fictifs hors des builds de production et changez leurs codes régulièrement : leurs ID tokens portent la même signature que ceux d'un vrai utilisateur.
Supabase Auth : captchaToken, Turnstile ou hCaptcha, et RLS après connexion
Supabase Auth propose hCaptcha ou Cloudflare Turnstile sur la connexion, l'inscription et la réinitialisation du mot de passe (Settings > Authentication > Bot and Abuse Protection). Le client transmet options.captchaToken à signUp, signInWithPassword, signInWithOtp et aux appels similaires. CaptchaAI ne résout pas hCaptcha : seuls les projets configurés sur Turnstile disposent d'une voie CaptchaAI.
En local, la CLI Supabase lit supabase/config.toml ; fournissez à la CI le secret de test Turnstile :
[auth.captcha]
enabled = true
provider = "turnstile"
secret = "env(TURNSTILE_SECRET_KEY)"
Avec le secret « passe toujours » chargé, les appels de CI passent captchaToken: "XXXX.DUMMY.TOKEN.XXXX" sans afficher de widget. Les exemples de Supabase gardant le token dans l'état du composant, la vérification de préproduction se connecte via supabase-js, puis prouve que la row-level security (RLS) ne renvoie que les lignes de l'utilisateur :
// supabase-smoke.ts: staging, synthetic user, Turnstile provider (run with: npx tsx supabase-smoke.ts)
import { createClient } from "@supabase/supabase-js";
import { solveWithCaptchaAI } from "./captchaai";
// Publishable (or legacy anon) key only.
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!);
const captchaToken = await solveWithCaptchaAI({
method: "turnstile",
sitekey: process.env.TURNSTILE_SITE_KEY!,
pageurl: `${process.env.APP_URL}/login`,
});
const { data, error } = await supabase.auth.signInWithPassword({
email: process.env.SYNTHETIC_USER_EMAIL!,
password: process.env.SYNTHETIC_USER_PASSWORD!,
options: { captchaToken },
});
if (error) throw new Error(`sign-in rejected: ${error.message}`);
// Requests now carry the user's JWT, so policies such as (select auth.uid()) = user_id apply.
const { data: rows, error: rlsError } = await supabase.from("projects").select("id, user_id");
if (rlsError) throw rlsError;
if (rows.some((r) => r.user_id !== data.user.id)) throw new Error("RLS leak: rows owned by another user");
console.log(`signed in as ${data.user.id}; RLS returned ${rows.length} own rows`);
Ne remplacez jamais la clé publishable par la clé secrète (sb_secret_, qui succède à service_role) dans un test censé se comporter comme un utilisateur : elle ignore la row-level security, et une politique cassée passerait inaperçue.
Keycloak : l'étape reCAPTCHA du flux d'inscription et le test E2E
Keycloak ajoute reCAPTCHA à l'auto-inscription, en quatre réglages :
- Ouvrez Authentication > Flows > Registration et passez l'étape reCAPTCHA à Required.
- Saisissez le sitekey et le secret sous l'icône d'engrenage de l'étape.
- Pour une clé à score, activez l'option reCAPTCHA v3 ; une étape Enterprise distincte existe aussi.
- Le widget de Google s'affiche dans une iframe : sous Realm Settings > Security Defenses, autorisez
https://www.google.comdans les en-têtes X-Frame-Options et Content-Security-Policy, sans quoi le widget ne s'affiche jamais.
Pour le realm de CI, kcadm.sh bascule l'étape sur la paire de test v2 de Google. L'identifiant de provider de l'étape est registration-recaptcha-action, et ses clés de configuration sont site.key, secret.key et recaptcha.v3 :
#!/usr/bin/env bash
# CI realm only: switch the registration reCAPTCHA step to Google's v2 test keys.
set -euo pipefail
KCADM="${KCADM:-/opt/keycloak/bin/kcadm.sh}"
REALM="${KC_REALM:-ci}"
"$KCADM" config credentials --server "$KC_URL" --realm master --user "$KC_ADMIN" --password "$KC_ADMIN_PASSWORD"
CONFIG_ID=$("$KCADM" get authentication/flows/registration/executions -r "$REALM" \
| jq -r '.[] | select(.providerId == "registration-recaptcha-action") | .authenticationConfig // empty')
if [ -z "$CONFIG_ID" ]; then
echo "No reCAPTCHA config in realm $REALM: save the step's gear-icon form once, then rerun." >&2
exit 1
fi
TMP=$(mktemp)
"$KCADM" get "authentication/config/$CONFIG_ID" -r "$REALM" \
| jq '.config["site.key"] = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
| .config["secret.key"] = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"
| .config["recaptcha.v3"] = "false"' > "$TMP"
"$KCADM" update "authentication/config/$CONFIG_ID" -r "$REALM" -f "$TMP"
rm -f "$TMP"
Keycloak est la solution où CaptchaAI s'intègre le plus directement : un formulaire HTML classique, dont le g-recaptcha-response est lu dans le POST puis vérifié auprès du siteverify de Google. La subtilité tient au pageurl. Le widget vit sur l'hôte Keycloak, après la redirection depuis votre application : CaptchaAI a besoin de cette URL-là (le helper lit page.url()), et la liste de domaines de la clé de préproduction doit inclure le nom d'hôte Keycloak.
// keycloak-register.spec.ts: staging realm with a real reCAPTCHA v2 key
import { test, expect } from "@playwright/test";
import { solveCaptchaOnPage } from "./captchaai";
test("synthetic registration through Keycloak @real-captcha", async ({ page }) => {
test.setTimeout(180_000);
await page.goto("/"); // the app redirects to the Keycloak login page
await page.getByRole("link", { name: "Register" }).click();
const id = `e2e-${Date.now()}`;
await page.locator("#username").fill(id);
await page.locator("#email").fill(`${id}@example.test`);
await page.locator("#firstName").fill("E2E");
await page.locator("#lastName").fill("Synthetic");
await page.locator("#password").fill(process.env.SYNTHETIC_USER_PASSWORD!);
await page.locator("#password-confirm").fill(process.env.SYNTHETIC_USER_PASSWORD!);
await solveCaptchaOnPage(page); // last, so the token is fresh
await page.locator('#kc-register-form [type="submit"]').click();
await expect(page).toHaveURL(new RegExp(process.env.APP_ORIGIN!));
});
Supprimez ensuite l'utilisateur avec kcadm.sh delete users/<id> -r <realm> et limitez-vous à une inscription par exécution ; pour la conception de ces tests, voyez notre guide des tests de parcours d'inscription. Une clé v3 exigerait version=v3 et l'action de l'étape (register par défaut), que ce helper n'envoie pas.
Dépannage des tests de connexion avec CAPTCHA
| Symptôme | Cause probable | Correctif |
|---|---|---|
| CAPTCHA signalé manquant après la résolution | Mauvais champ, ou token lu depuis le callback du widget | Alignez le nom posté (préfixe ulp- chez Auth0) ; pour les applications pilotées par callback, testez au niveau API |
Siteverify renvoie timeout-or-duplicate |
Token expiré ou réutilisé | Remplissez d'abord le formulaire, résolvez en dernier, soumettez aussitôt |
ERROR_BAD_TOKEN_OR_PAGEURL (reCAPTCHA) |
Sitekey d'un autre widget, ou pageurl qui désigne l'application et non l'hôte du widget |
Lisez les deux sur la page qui affiche le widget |
| Échecs de résolution après un changement de configuration | Passage à hCaptcha, Arkose ou BotID | CaptchaAI ne les résout pas ; basculez le test sur le mode test de l'éditeur |
| Des connexions scriptées passent en production | Un secret de test a été déployé | Restaurez le vrai secret et conservez le garde-fou au démarrage |
ERROR_ZERO_BALANCE |
Tous les threads du plan sont occupés, ou aucun plan actif | Lancez les sondes l'une après l'autre ; vérifiez action=threadsinfo |
ERROR_WRONG_USER_KEY |
Secret CI manquant ou tronqué | Corrigez CAPTCHAAI_API_KEY ; ne relancez jamais en boucle |
Questions fréquentes
Faut-il une clé CaptchaAI pour faire tourner la CI ?
Non. La CI tourne sur les clés de test, les Testing Tokens, l'émulateur ou l'IP AllowList, sans appel à CaptchaAI. La clé ne sert qu'à la préproduction et à la sonde, et ne doit être exposée qu'à ces deux jobs.
Et si le fournisseur est configuré sur hCaptcha ?
CaptchaAI ne prend pas encore en charge hCaptcha, ni Arkose (FunCaptcha). Utilisez les clés de test de l'éditeur, ou l'IP AllowList d'Auth0, dans tous les environnements. Si vous avez le choix, Supabase ou Better Auth configuré sur Turnstile garde une voie CaptchaAI en préproduction.
Combien de threads prévoir pour une sonde de production ?
Très peu. Une sonde lancée toutes les 15 à 30 minutes n'occupe un thread que pendant sa résolution, en général moins de 10 secondes pour Turnstile et moins de 60 pour reCAPTCHA v2. Le plan BASIC ($15/mois, 5 threads) laisse de la marge pour la suite nocturne ; si ERROR_ZERO_BALANCE apparaît, sérialisez les vérifications avant de changer d'offre.
Comment empêcher qu'une clé de test parte en production ?
Avec deux verrous : le garde-fou présenté plus haut refuse de démarrer la production si une clé de test figure dans l'environnement, et les variables de test ne vivent que dans le job e2e-ci. Des connexions scriptées qui passent en production restent le symptôme à surveiller.
Prochaine étape : un premier test E2E avec vrai CAPTCHA en préproduction
Laissez la CI sur le mode test, puis ajoutez un seul test @real-captcha en préproduction avec le helper ci-dessus, là où une résolution a du sens : Auth.js, Keycloak, Better Auth et Supabase configurés sur Turnstile, ainsi qu'Auth0 avec votre propre partial Turnstile.
Obtenez une clé API CaptchaAI pour vos vérifications de connexion en préproduction