Integrationen

Lösen von CAPTCHAs in React Native WebViews mit CaptchaAI

Eine CAPTCHA-Abfrage in einem react-native-webview lässt sich nicht im nativen App-Code wegklicken: Sie lebt im eingebetteten Webkontext, und Ihre App sieht davon nur das, was Sie sich aktiv herausreichen lassen. Der tragfähige Weg besteht aus drei Stationen – Sitekey im WebView auslesen, die Lösung serverseitig bei CaptchaAI anfordern, das Token zurück in das Formularfeld schreiben.

Genau diesen Ablauf zeigt der Artikel Schritt für Schritt – für reCAPTCHA v2 und Cloudflare Turnstile, mit lauffähigem Code für WebView-Komponente und Node.js-Backend. Dazu die zwei Punkte, an denen mobile Integrationen meist scheitern: der Token-Ablauf und ein API-Schlüssel, der im App-Bundle nichts verloren hat.

Warum WebViews ein eigener Fall sind

Bei klassischer Browser-Automatisierung steuern Sie die Seite direkt. Im WebView liegt zwischen App und Seite eine Brücke, und daraus folgen drei Konsequenzen für die Praxis:

  • Kein direkter DOM-Zugriff. Erkennung und Token-Übergabe laufen über injectedJavaScript und window.ReactNativeWebView.postMessage().
  • Der API-Schlüssel gehört auf den Server. Alles, was in der App liegt, lässt sich aus dem Bundle herauslesen – deshalb übernimmt ein eigenes Backend die CaptchaAI-Aufrufe.
  • Mobile Sitzungen werden ständig unterbrochen. Wechselt die App in den Hintergrund, läuft die Zeit weiter: reCAPTCHA-v2-Tokens gelten rund 120 Sekunden, Turnstile-Tokens rund 300 Sekunden.

Architektur: drei Schichten, ein Schlüssel

Der Ablauf verteilt sich auf drei Zuständigkeiten:

Schicht Aufgabe
React Native WebView erkennt die CAPTCHA-Abfrage, liest den Sitekey aus und trägt das gelöste Token ein
Backend-API (Node.js) nimmt Sitekey und Page-URL entgegen, ruft CaptchaAI auf und gibt das Token zurück
CaptchaAI-API löst die Abfrage und liefert das Token

Zwischen WebView und App-Code vermittelt postMessage(); der API-Schlüssel bleibt im Backend.

Praxisbeispiel: Terminformular im Kundenportal

Ein Serviceteam in Köln betreut eine React-Native-App, die das Terminformular eines Partnerbetriebs in ein WebView einbettet – eine im DACH-Mittelstand alltägliche Konstellation, wenn der Partner kein eigenes Mobile-SDK anbietet. Vor dem Absenden steht ein reCAPTCHA-v2-Kontrollkästchen. Der Solver-Dienst läuft als schlanker Node.js-Prozess auf einem Hetzner-Server in Nürnberg und wird über GitLab CI ausgerollt; die App selbst kennt nur die eigene Backend-URL.

Die Aufgabe zerfällt in vier Schritte:

  1. Das CAPTCHA-Widget erkennen, sobald das WebView fertig geladen hat
  2. Den Sitekey aus dem DOM auslesen
  3. Die Lösung über die CaptchaAI-API vom Backend aus anfordern
  4. Das Token zurück in das WebView schreiben und das Formular absenden

Umgebung: React Native 0.72+, react-native-webview 13+, Node.js-Backend, CaptchaAI-API.

Schritt 1: CAPTCHA erkennen und Sitekey auslesen

Das injectedJavaScript-Prop führt Ihr Skript aus, sobald die Seite geladen ist. Es prüft nacheinander auf ein reCAPTCHA-v2- und ein Turnstile-Widget und meldet Typ, Sitekey und Page-URL per postMessage() an die App zurück:

// 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>
  );
}

Schritt 2: Backend-Solver mit Node.js aufsetzen

Das Backend nimmt Sitekey und Page-URL entgegen, übermittelt die Aufgabe an in.php, fragt das Ergebnis an res.php ab und antwortet mit dem Token. Achten Sie auf die Parameterwahl: reCAPTCHA v2 arbeitet mit method=userrecaptcha und googlekey, Turnstile mit method=turnstile und sitekey. Der Schlüssel kommt aus der Umgebung, niemals aus dem App-Code:

// 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'));

Schritt 3: Token-Ablauf im WebView abfangen

Der häufigste Fehler in mobilen Integrationen ist ein Token, das beim Absenden längst abgelaufen ist – etwa weil der Nutzer das Formular erst nach einem Anruf zu Ende ausfüllt. Prüfen Sie deshalb unmittelbar vor dem Absenden den Zeitstempel und lösen Sie bei Überschreitung der eigenen TTL neu:

// 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;
  `);
};

Lösungszeiten realistisch einplanen

Für die Wartezeit im UI gelten die veröffentlichten Obergrenzen: Cloudflare Turnstile wird in unter 10 Sekunden gelöst, unsichtbares reCAPTCHA v2 in unter 30 Sekunden, das klassische reCAPTCHA-v2-Kontrollkästchen in unter 60 Sekunden. Die Erfolgsquote auf den unterstützten Typen ist hoch; eine feste Prozentzahl nennen wir bewusst nicht, weil sie je nach Zielseite schwankt.

Für die App heißt das: ActivityIndicator anzeigen, den Absenden-Button sperren und nach einem definierten Timeout mit verständlicher Meldung abbrechen. Ein stiller Spinner ohne Abbruchweg wirkt in Nutzertests wie ein Absturz.

Kosten: Threads statt Einzelabrechnung

CaptchaAI rechnet nach gleichzeitigen Threads ab, nicht pro gelöstem CAPTCHA; die Zahl der Lösungen pro Thread ist im Plan nicht gedeckelt. Ein Thread ist eine laufende Lösungsanfrage – nicht ein Nutzer, nicht ein Gerät. BASIC (15 $/Monat, 5 Threads) trägt Testbetrieb und kleine Nutzerkreise, STANDARD (30 $/Monat, 15 Threads) den Regelbetrieb einer mittleren App, ADVANCE (90 $/Monat, 50 Threads) deutliche Lastspitzen. Alle Preise in US-Dollar.

DSGVO-Blick vor dem Rollout

Die Page-URL, die Sie an den Solver übergeben, kann Parameter mit Personenbezug enthalten – Kundennummer, Vorgangs- oder Termin-ID. Zwei Punkte lohnen die Prüfung, bevor die Integration produktiv geht:

  • URLs vor der Weitergabe an das Backend um überflüssige Query-Parameter kürzen.
  • Im Verarbeitungsverzeichnis festhalten, welche Daten an welchen Dienstleister fließen; IP-Adressen und Nutzungsdaten zählen als personenbezogene Daten.

Hinweis: Diese Prüfung liegt in Ihrer Verantwortung; sie ist keine Aussage über Zertifizierungen des Anbieters.

Fehlerbilder und ihre Ursachen

Symptom Wahrscheinliche Ursache Vorgehen
onMessage erhält nichts Handler nicht gebunden oder Fehler im injizierten Skript Skript in try/catch kapseln und die Bindung von onMessage prüfen
Backend meldet ERROR_BAD_TOKEN_OR_PAGEURL Sitekey und Page-URL passen nicht zusammen Sitekey aus dem tatsächlichen Widget-Kontext lesen, nicht aus der Elternseite
Token gesetzt, Formular reagiert nicht Der Callback der Seite wird nicht ausgelöst Die Client-Objekte durchlaufen und den Callback direkt aufrufen (siehe Schritt 1)
CAPCHA_NOT_READY ohne Ende Abfrageintervall zu kurz oder Parameter falsch Alle 5–10 Sekunden abfragen, Timeout von echten Fehlercodes trennen und die Ursache loggen
WebView zeigt ein leeres CAPTCHA JavaScript deaktiviert oder Inhalt blockiert javaScriptEnabled={true} setzen und die Content-Security-Policy der Zielseite prüfen

Häufige Fragen

Welche CAPTCHA-Typen deckt diese Integration ab?

Alle von CaptchaAI unterstützten Typen – die Brücke im WebView bleibt dieselbe, es ändern sich nur method und die Parameter:

  • Regulär: reCAPTCHA v2 (auch Invisible und Enterprise), reCAPTCHA v3, Cloudflare Turnstile und Challenge, GeeTest v3, Bild-, Raster- und BLS-CAPTCHAs.
  • Beta: CaptchaFox, Friendly Captcha und Lemin.
  • Nicht dabei: hCaptcha und FunCaptcha; GeeTest v4 ist bislang nur als „bald verfügbar“ angekündigt.

Was passiert, wenn die App während des Lösens in den Hintergrund wechselt?

Der Auftrag im Backend läuft weiter; das Ergebnis liegt bereit, sobald die App zurückkehrt. Kritisch ist allein die Restlaufzeit des Tokens. Prüfen Sie beim Reaktivieren den Zeitstempel und lösen Sie neu, bevor Sie absenden.

Muss ich das WebView nach dem Eintragen des Tokens neu laden?

Nein. injectJavaScript() arbeitet im laufenden Seitenkontext, ein Reload würde das Token sogar verwerfen. Nur wenn die Zielseite das Formular selbst neu aufbaut, müssen Erkennung und Lösung erneut anlaufen.

Wie viele Threads braucht eine mobile App?

Maßgeblich ist die Zahl gleichzeitig offener Lösungsanfragen, nicht die Zahl der Installationen. Da pro Formular nur eine Abfrage anfällt und Sekunden später erledigt ist, kommen die meisten Apps mit wenigen Threads aus. Messen Sie die Spitzenlast im Backend-Logging und dimensionieren Sie danach.

Lässt sich der Ablauf in der CI-Pipeline testen?

Ja. Legen Sie eine Testseite mit den offiziellen Test-Sitekeys auf einer eigenen Staging-Domain ab (https://staging.example-app.test/formular) und lassen Sie Erkennung und Token-Übergabe dort gegen ein Mock-Backend laufen. So prüfen Sie die Brücke, ohne bei jedem Pipeline-Lauf echte Lösungen zu verbrauchen.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.