Integrations

Résolution des CAPTCHA dans React Native WebViews avec CaptchaAI

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 :

  1. Détecter le widget CAPTCHA une fois le chargement du WebView terminé
  2. Extraire le sitekey du DOM
  3. Faire résoudre le défi via l'API CaptchaAI depuis un service backend
  4. Réinjecter le token dans le WebView et envoyer le formulaire

Prérequis :

  • React Native 0.72+ et react-native-webview 13+
  • 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.php chaque 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 :

Articles connexes

Les commentaires sont désactivés pour cet article.