Un WebView React Native n'a aucun moyen de valider un CAPTCHA par lui-même : il faut lire le sitekey dans le DOM, faire résoudre le défi côté serveur, puis réinjecter le token avant l'envoi du formulaire. C'est cet aller-retour qui décide de la fiabilité de votre parcours mobile. react-native-webview charge souvent des formulaires tiers protégés par une case reCAPTCHA v2 ou un widget Cloudflare Turnstile : CaptchaAI prend en charge ces deux types et renvoie un token exploitable.
Ce qui reste dans le WebView, ce qui part côté serveur
| Couche | Rôle |
|---|---|
| WebView React Native | Détecte le défi CAPTCHA, lit le sitekey, réinjecte le token |
| Backend Node.js | Reçoit le sitekey et la pageurl, appelle l'API CaptchaAI, renvoie le token |
| API CaptchaAI | Résout reCAPTCHA v2 ou Cloudflare Turnstile et renvoie le token |
Cette frontière tient à trois décisions :
- La clé API ne quitte jamais le serveur. Le WebView dialogue avec votre code via
window.ReactNativeWebView.postMessage(); un binaire mobile se décompile, et une clé embarquée se lit en quelques minutes. - Le backend reste proche des utilisateurs. OVHcloud, Scaleway ou la région AWS eu-west-3 (Paris) évitent un aller-retour transatlantique à chaque défi.
- Seules deux valeurs sortent de l'application. Le sitekey et l'URL de la page suffisent : côté RGPD, aucune donnée personnelle n'a à transiter.
Un cas concret : formulaire partenaire dans une application de terrain
Une application de suivi d'interventions, utilisée par des techniciens en France et en Belgique, ouvre le portail d'un partenaire logistique dans un WebView. Le formulaire de confirmation de tournée porte une case reCAPTCHA v2 : tant qu'elle n'est pas validée, le technicien reste bloqué. Le trajet tient en quatre temps :
- Détecter le widget CAPTCHA une fois le chargement du WebView terminé
- Extraire le sitekey du DOM
- Faire résoudre le défi via l'API CaptchaAI depuis un service backend
- Réinjecter le token dans le WebView et envoyer le formulaire
Prérequis :
- React Native 0.72+ et
react-native-webview13+ - Un service backend Node.js joignable depuis l'application
- Une clé API CaptchaAI
Étape 1 : détecter le défi et lire le sitekey dans le WebView
La propriété injectedJavaScript exécute votre script une fois la page chargée. Faites-lui chercher .g-recaptcha et .cf-turnstile, puis remonter le sitekey et la pageurl vers React Native :
// CaptchaDetector.js — React Native Component
import React, { useRef, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { WebView } from 'react-native-webview';
const CAPTCHA_DETECTION_SCRIPT = `
(function() {
// Detect reCAPTCHA v2
const recaptchaDiv = document.querySelector('.g-recaptcha');
if (recaptchaDiv) {
const sitekey = recaptchaDiv.getAttribute('data-sitekey');
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'captcha_detected',
captchaType: 'recaptcha_v2',
sitekey: sitekey,
pageurl: window.location.href
}));
return;
}
// Detect Cloudflare Turnstile
const turnstileDiv = document.querySelector('.cf-turnstile');
if (turnstileDiv) {
const sitekey = turnstileDiv.getAttribute('data-sitekey');
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'captcha_detected',
captchaType: 'turnstile',
sitekey: sitekey,
pageurl: window.location.href
}));
return;
}
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'no_captcha'
}));
})();
true;
`;
export default function CaptchaWebView({ url }) {
const webviewRef = useRef(null);
const [solving, setSolving] = useState(false);
const handleMessage = async (event) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'captcha_detected') {
setSolving(true);
try {
const token = await solveCaptchaViaBackend(
data.captchaType,
data.sitekey,
data.pageurl
);
injectToken(data.captchaType, token);
} catch (err) {
console.error('CAPTCHA solve failed:', err.message);
} finally {
setSolving(false);
}
}
};
const solveCaptchaViaBackend = async (captchaType, sitekey, pageurl) => {
const response = await fetch('https://your-backend.com/api/solve-captcha', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ captchaType, sitekey, pageurl }),
});
const result = await response.json();
if (!result.token) throw new Error(result.error || 'No token returned');
return result.token;
};
const injectToken = (captchaType, token) => {
let script;
if (captchaType === 'recaptcha_v2') {
script = `
document.getElementById('g-recaptcha-response').value = '${token}';
if (typeof ___grecaptcha_cfg !== 'undefined') {
Object.keys(___grecaptcha_cfg.clients).forEach(key => {
const client = ___grecaptcha_cfg.clients[key];
Object.keys(client).forEach(k => {
const item = client[k];
if (item && item.callback) {
item.callback('${token}');
}
});
});
}
true;
`;
} else if (captchaType === 'turnstile') {
script = `
const input = document.querySelector('[name="cf-turnstile-response"]');
if (input) input.value = '${token}';
const callback = document.querySelector('.cf-turnstile')
?.getAttribute('data-callback');
if (callback && typeof window[callback] === 'function') {
window[callback]('${token}');
}
true;
`;
}
webviewRef.current?.injectJavaScript(script);
};
return (
<View style={{ flex: 1 }}>
{solving && <ActivityIndicator size="large" />}
<WebView
ref={webviewRef}
source={{ uri: url }}
injectedJavaScript={CAPTCHA_DETECTION_SCRIPT}
onMessage={handleMessage}
javaScriptEnabled={true}
/>
</View>
);
}
Étape 2 : router la résolution par un backend Node.js
Le backend envoie la tâche à in.php, interroge res.php jusqu'au token, puis le renvoie au WebView. method=userrecaptcha couvre reCAPTCHA v2, method=turnstile couvre Turnstile :
// server.js — Express backend
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const API_KEY = process.env.CAPTCHAAI_API_KEY || 'YOUR_API_KEY';
app.post('/api/solve-captcha', async (req, res) => {
const { captchaType, sitekey, pageurl } = req.body;
try {
// Step 1: Submit task to CaptchaAI
const submitParams = {
key: API_KEY,
pageurl: pageurl,
json: '1',
};
if (captchaType === 'recaptcha_v2') {
submitParams.method = 'userrecaptcha';
submitParams.googlekey = sitekey;
} else if (captchaType === 'turnstile') {
submitParams.method = 'turnstile';
submitParams.sitekey = sitekey;
}
const submitResponse = await axios.get(
'https://ocr.captchaai.com/in.php',
{ params: submitParams }
);
if (submitResponse.data.status !== 1) {
return res.status(400).json({ error: submitResponse.data.request });
}
const taskId = submitResponse.data.request;
// Step 2: Poll for result
const token = await pollForResult(taskId);
res.json({ token });
} catch (error) {
console.error('Solve error:', error.message);
res.status(500).json({ error: 'Failed to solve CAPTCHA' });
}
});
async function pollForResult(taskId, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 5000));
const response = await axios.get('https://ocr.captchaai.com/res.php', {
params: {
key: API_KEY,
action: 'get',
id: taskId,
json: '1',
},
});
if (response.data.status === 1) {
return response.data.request;
}
if (
response.data.request !== 'CAPCHA_NOT_READY' &&
response.data.status === 0
) {
throw new Error(response.data.request);
}
}
throw new Error('Polling timeout — CAPTCHA not solved in time');
}
app.listen(3000, () => console.log('Solver backend running on port 3000'));
Deux détails comptent dans cette boucle :
- Espacez l'interrogation de cinq secondes : appeler
res.phpchaque seconde n'accélère rien. - Gardez le plafond de trente tentatives, sans quoi une requête mobile peut rester ouverte indéfiniment.
Étape 3 : surveiller la durée de vie du token avant l'envoi
Un token vit peu de temps :
- reCAPTCHA v2 — environ 120 secondes
- Turnstile — environ 300 secondes
Sur mobile, l'écart entre résolution et envoi se creuse vite : l'utilisateur bascule d'application, perd le réseau, revient trois minutes plus tard. Horodatez le token et redemandez-en un dès que le délai est dépassé :
// Add to CaptchaWebView component
const [tokenTimestamp, setTokenTimestamp] = useState(null);
const TOKEN_TTL_MS = 110000; // 110 seconds for reCAPTCHA v2
const handleFormSubmit = async (captchaType, sitekey, pageurl) => {
const now = Date.now();
if (!tokenTimestamp || now - tokenTimestamp > TOKEN_TTL_MS) {
const freshToken = await solveCaptchaViaBackend(
captchaType, sitekey, pageurl
);
injectToken(captchaType, freshToken);
setTokenTimestamp(Date.now());
}
webviewRef.current?.injectJavaScript(`
document.querySelector('form').submit();
true;
`);
};
Quel plan prévoir pour un trafic mobile
La facturation se fait au thread simultané, pas à la résolution : un thread traite un défi à la fois, avec un nombre de résolutions illimité sur le mois. Dimensionnez sur vos pics de simultanéité :
- BASIC ($15/mois, 5 threads) — application interne
- STANDARD ($30/mois, 15 threads) — pic de connexions matinal
- ADVANCE ($90/mois, 50 threads) — parc d'applications partagé
Facturation en dollars US. Les plafonds annoncés sont de moins de 60 s pour reCAPTCHA v2 et de moins de 10 s pour Turnstile : calez votre indicateur de chargement dessus.
Dépannage
| Problème | Cause probable | Correctif |
|---|---|---|
postMessage jamais reçu |
onMessage non branché, ou erreur dans le script injecté |
Vérifiez la liaison et enveloppez le script dans un try/catch |
ERROR_BAD_TOKEN_OR_PAGEURL |
Le sitekey ne correspond pas à l'URL envoyée | Lisez le sitekey dans le src de l'iframe, pas dans la page parente |
| Token injecté, mais rien ne se déclenche | Callback introuvable dans ___grecaptcha_cfg |
Parcourez les objets clients et leurs propriétés imbriquées |
CAPCHA_NOT_READY sans fin |
Résolution lente ou paramètres invalides | Augmentez le nombre de tentatives, revérifiez sitekey et pageurl |
| Widget vide dans le WebView | JavaScript désactivé, ou contenu bloqué par la CSP | Passez javaScriptEnabled={true} et autorisez les domaines du widget |
FAQ
Le workflow managé d'Expo est-il compatible ?
Oui, à partir d'Expo SDK 49 avec expo-dev-client. L'injection JavaScript et le passage de messages se comportent comme dans un projet bare.
Comment récupérer un sitekey affiché dans une iframe tierce ?
Dans l'attribut src de l'iframe du widget, jamais dans le DOM de la page parente : les deux valeurs diffèrent souvent, et c'est la première cause de token refusé.
Faut-il gérer les nouvelles tentatives côté application ?
Plutôt côté backend : un backoff exponentiel sur deux ou trois tentatives absorbe les coupures réseau sans multiplier les tâches envoyées à l'API.
CaptchaAI peut-il résoudre un hCaptcha affiché dans le WebView ?
Non — hCaptcha n'est pas pris en charge, pas plus que FunCaptcha (Arkose Labs) ; GeeTest v4 est annoncé comme à venir. Les types couverts : reCAPTCHA v2 et v3, Cloudflare Turnstile et Challenge, GeeTest v3, les CAPTCHA image et grille, plus CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta).
Prochaines étapes
Le pont est en place : détection, résolution côté serveur, réinjection sous contrôle du TTL. Récupérez votre clé API CaptchaAI et branchez le backend sur votre première application.
Guides associés :
- Gérer les CAPTCHA dans l'automatisation mobile avec Appium
- Extraire le sitekey Cloudflare Turnstile depuis la source de la page
- Extraire les paramètres reCAPTCHA depuis la source de la page