Ein Turnstile-Widget kostet Sie in Node.js genau drei Arbeitsschritte: den Sitekey aus dem HTML lesen, ein Token über die CaptchaAI-API anfordern und dieses Token als cf-turnstile-response mit dem Formular absenden. Alles Weitere – Polling, Timeouts, die Zusatzparameter action und cData – ist Feinschliff um diese drei Schritte herum.
Im deutschsprachigen Raum sitzt Turnstile inzwischen überall dort, wo früher reCAPTCHA stand: vor Kunden-Logins in Shopware- und JTL-Shops, vor Buchungsmasken, vor Kontaktformularen auf Hetzner- oder IONOS-Servern. Wer eigene Anwendungen automatisiert testet, stolpert deshalb in jedem Node.js-Skript an derselben Stelle. Den Weg vom rohen HTML bis zur serverseitigen Prüfung zeigen die nächsten Abschnitte vollständig.
Der Ablauf in vier Zügen
Bei Login, Registrierung und Kontaktformular ist der Ablauf identisch:
- Sitekey ermitteln – der öffentliche Schlüssel des Widgets, bei Turnstile stets mit
0xbeginnend. - Lösung anfordern – Sitekey und Seiten-URL gehen an
in.php, das Ergebnis holen Sie vonres.php. - Token einsetzen – der zurückgegebene Wert wandert in das Feld
cf-turnstile-response. - Antwort auswerten – Statuscode und Weiterleitung zeigen, ob die Anmeldung durchlief.
Ein Turnstile-Token ist kurzlebig: rund 120 Sekunden gültig, genau einmal einlösbar. Fordern Sie es deshalb erst an, wenn das Formular fertig ausgefüllt ist.
Voraussetzungen
- Node.js 18 oder neuer – ab dieser Version ist
fetcheingebaut, ein HTTP-Paket brauchen Sie nicht. - Einen CaptchaAI-API-Schlüssel aus dem Dashboard; im Code steht dafür
YOUR_API_KEY. - Eine Seite, die Sie automatisieren dürfen: eigene Anwendung, Staging-Umgebung oder ein System mit Freigabe.
CaptchaAI rechnet Thread-basiert ab, nicht pro Lösung: Ein Thread ist eine laufende CAPTCHA-Abfrage und wird danach für die nächste Aufgabe frei; die Zahl der Lösungen im Monat ist nicht gedeckelt. Für ein einzelnes Node.js-Skript genügt BASIC mit 15 $ im Monat und 5 Threads. Preise in US-Dollar.
Schritt 1: Den Sitekey aus dem HTML lesen
Jedes Turnstile-Widget trägt einen öffentlichen Sitekey mit dem Präfix 0x – daran unterscheiden Sie ihn von einem reCAPTCHA-Schlüssel, der mit 6L anfängt. Wo der Wert steht, hängt von der Integration ab: mal als data-sitekey am Container cf-turnstile, mal in einem turnstile.render-Aufruf im Inline-Skript. Die folgende Funktion prüft vier Fundstellen nacheinander.
async function extractTurnstileSitekey(url) {
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
});
const html = await resp.text();
// Method 1: data-sitekey attribute on Turnstile div
const divMatch = html.match(
/class=["'][^"]*cf-turnstile[^"]*["'][^>]*data-sitekey=["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (divMatch) return divMatch[1];
// Method 2: data-sitekey on any element (Turnstile keys start with 0x)
const attrMatch = html.match(
/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/
);
if (attrMatch) return attrMatch[1];
// Method 3: In JavaScript turnstile.render call
const jsMatch = html.match(
/turnstile\.render\s*\([^,]+,\s*\{[^}]*sitekey\s*:\s*["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (jsMatch) return jsMatch[1];
// Method 4: Generic sitekey in inline script
const inlineMatch = html.match(
/sitekey\s*:\s*["'](0x[A-Za-z0-9_-]+)["']/
);
if (inlineMatch) return inlineMatch[1];
return null;
}
Liefert sie null, wird das Widget erst im Browser nachgeladen; ein reiner HTTP-Abruf sieht davon nichts. Dann öffnen Sie die Seite mit Puppeteer oder Playwright und lesen den Sitekey aus dem gerenderten DOM.
Schritt 2: Das Token über die CaptchaAI-API anfordern
Die API arbeitet zweistufig: in.php nimmt die Aufgabe entgegen und antwortet mit einer Task-ID, res.php liefert das fertige Token. Für Turnstile setzen Sie method=turnstile und übergeben sitekey sowie pageurl – exakt die Adresse, unter der das Widget eingebettet ist, inklusive Protokoll und Pfad.
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveTurnstile(sitekey, pageurl, action = null) {
// Submit task
const submitData = {
key: API_KEY,
method: "turnstile",
sitekey: sitekey,
pageurl: pageurl,
json: "1",
};
if (action) {
submitData.action = action;
}
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams(submitData),
});
const submitResult = await submitResp.json();
if (submitResult.status !== 1) {
throw new Error(`Submit error: ${submitResult.request}`);
}
const taskId = submitResult.request;
console.log(`Task ID: ${taskId}`);
// Poll for result
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const pollResult = await pollResp.json();
if (pollResult.status === 1) {
return pollResult.request;
}
if (pollResult.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Turnstile unsolvable");
}
}
throw new Error("Solve timed out");
}
Zwei Details verdienen Aufmerksamkeit. Das Polling-Intervall von fünf Sekunden ist ein brauchbarer Kompromiss, denn Turnstile wird in der Regel in unter 10 Sekunden gelöst. ERROR_CAPTCHA_UNSOLVABLE wiederum ist für die konkrete Aufgabe endgültig: Ein erneuter Versuch beginnt mit einer frischen Übermittlung – sinnvollerweise mit exponentiellem Backoff – statt weiter dieselbe Task-ID abzufragen.
Schritt 3: Das Token als cf-turnstile-response absenden
Turnstile erwartet das gelöste Token im Formularfeld cf-turnstile-response. Senden Sie es gemeinsam mit den übrigen Formulardaten als application/x-www-form-urlencoded ab – genau so, wie es der Browser täte.
async function submitTurnstileForm(url, formData, token) {
const body = new URLSearchParams({
...formData,
"cf-turnstile-response": token,
});
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
body,
});
return {
status: resp.status,
body: await resp.text(),
};
}
Der realistische User-Agent ist kein Beiwerk: Antwortet der Server mit 403, obwohl das Token gültig ist, liegt das meist an unvollständigen Request-Headern – nicht an der Lösung.
Der komplette Turnstile-Login in Node.js
Zusammengesetzt ergeben die drei Funktionen einen Ablauf, der sich in jeden Test-Runner einhängen lässt – erst Sitekey, dann Lösung, dann Absenden. So entsteht das Token so spät wie möglich.
async function loginWithTurnstile(loginUrl, credentials) {
// Step 1: Extract sitekey
const sitekey = await extractTurnstileSitekey(loginUrl);
if (!sitekey) {
throw new Error("Turnstile sitekey not found");
}
console.log(`Sitekey: ${sitekey}`);
// Step 2: Solve Turnstile
const token = await solveTurnstile(sitekey, loginUrl);
console.log(`Token: ${token.substring(0, 50)}...`);
// Step 3: Submit form
const result = await submitTurnstileForm(loginUrl, credentials, token);
console.log(`Result: ${result.status}`);
return result;
}
// Usage
const result = await loginWithTurnstile("https://example.com/login", {
email: "[email protected]",
password: "pass123",
});
Turnstile-Solver-Klasse für den Dauerbetrieb
Für einen einzelnen Test genügen lose Funktionen. Sobald mehrere Skripte denselben API-Schlüssel verwenden, wird eine Klasse übersichtlicher: Der Schlüssel liegt in einem privaten Feld, Erkennung und Lösung sind je eine Methode.
class TurnstileSolver {
#apiKey;
constructor(apiKey) {
this.#apiKey = apiKey;
}
async solve(sitekey, pageurl, options = {}) {
const taskId = await this.#submit(sitekey, pageurl, options);
return await this.#poll(taskId);
}
async detectAndSolve(url) {
const sitekey = await this.#detect(url);
if (!sitekey) throw new Error("No Turnstile found");
return await this.solve(sitekey, url);
}
async #detect(url) {
const resp = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0" },
});
const html = await resp.text();
const match = html.match(/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/);
return match ? match[1] : null;
}
async #submit(sitekey, pageurl, options) {
const body = new URLSearchParams({
key: this.#apiKey,
method: "turnstile",
sitekey,
pageurl,
json: "1",
...(options.action && { action: options.action }),
...(options.cdata && { data: options.cdata }),
});
const resp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body,
});
const data = await resp.json();
if (data.status !== 1) throw new Error(`Submit: ${data.request}`);
return data.request;
}
async #poll(taskId) {
const params = new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
});
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const resp = await fetch(`https://ocr.captchaai.com/res.php?${params}`);
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Unsolvable");
}
}
throw new Error("Timed out");
}
}
// Usage
const solver = new TurnstileSolver("YOUR_API_KEY");
const token = await solver.detectAndSolve("https://example.com/login");
Diese Klasse hängen Sie in eine Warteschlange; wie viele Abfragen gleichzeitig laufen, begrenzt die Zahl Ihrer Threads.
Wann action und cData nötig sind
Manche Integrationen binden das Widget mit Zusatzparametern ein: action benennt den Kontext, etwa login, und cData transportiert eine seitenspezifische Zeichenkette, häufig eine Session-Kennung. Fehlen diese Werte, obwohl die Seite sie erwartet, ist das Token formal in Ordnung und wird trotzdem abgelehnt. Ein Blick ins HTML auf data-action und data-cdata klärt das.
// Extract action from the page
function extractTurnstileAction(html) {
const match = html.match(
/data-action=["']([^"']+)["']|action\s*:\s*["']([^"']+)["']/
);
return match ? match[1] || match[2] : null;
}
// Solve with action
const token = await solver.solve(sitekey, pageurl, {
action: "login",
cdata: "session_abc123",
});
Tokens serverseitig prüfen
Sichern Sie ein eigenes Formular mit Turnstile ab, brauchen Sie die Gegenrichtung: Cloudflares siteverify-Endpunkt. Ihr geheimer Schlüssel gehört ausschließlich auf den Server, nie in Client-Code oder ins Repository.
async function verifyTurnstileToken(token, ip) {
const resp = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: "YOUR_TURNSTILE_SECRET_KEY",
response: token,
remoteip: ip,
}),
}
);
const data = await resp.json();
return data.success;
}
Ein success: false nennt unter error-codes den Grund – meist ein abgelaufenes oder bereits eingelöstes Token.
Praxisbeispiel: nächtliche Login-Tests in einem DACH-Shop
Ein Entwicklerteam betreibt einen Shopware-Shop auf Hetzner-Servern und schützt den Kunden-Login seit dem letzten Release mit Turnstile. In der GitLab-CI läuft nachts eine End-to-End-Suite gegen das eigene Staging-System – seit der Umstellung scheitert jeder Lauf an der Anmeldung.
Die Korrektur ist genau der Ablauf von oben: Der CI-Job liest den Sitekey von der Staging-URL, fordert das Token über CaptchaAI an und trägt es vor dem Absenden ins Login-Formular ein. Bei unter 10 Sekunden Lösungszeit verlängert das den Durchlauf kaum, und weil die Suite seriell arbeitet, reicht ein einzelner Thread.
Zwei Punkte, die in DACH-Projekten regelmäßig aufkommen: Automatisieren Sie ausschließlich eigene oder freigegebene Systeme, und behandeln Sie Testdaten DSGVO-konform – IP-Adressen gehören auch in Staging nicht im Klartext in die CI-Logs.
Fehlerbilder und ihre Ursachen
| Symptom | Ursache | Lösung |
|---|---|---|
Sitekey beginnt mit 6Le |
reCAPTCHA, nicht Turnstile | method=userrecaptcha verwenden |
| Token abgelehnt | Falscher Sitekey oder abgelaufen | Sitekey neu auslesen, kurz vor dem Absenden lösen |
| Kein Sitekey im HTML | Widget per JavaScript nachgeladen | Mit Puppeteer oder Playwright rendern |
ERROR_BAD_PARAMETERS |
sitekey oder pageurl fehlt |
Beide Werte prüfen |
| 403 nach dem Absenden | Header passen nicht zur Anfrage | Realistischen User-Agent senden |
action wirkungslos |
Parameter fehlt in der Übermittlung | data-action auslesen und mitschicken |
Häufige Fragen
Wie lange bleibt ein Turnstile-Token gültig?
Rund 120 Sekunden, und einlösen lässt es sich genau einmal. Bauen Sie Ihr Skript deshalb so, dass zwischen Lösung und Absenden möglichst wenig passiert – langsame Vorbereitungsschritte gehören davor, nicht dazwischen.
Brauche ich Puppeteer oder reicht fetch?
Für die meisten Seiten reicht fetch: Sitekey aus dem HTML lesen, Token anfordern, Formular absenden. Ein Headless-Browser wird erst nötig, wenn das Widget dynamisch nachgeladen wird oder die Seite ohne JavaScript-Ausführung kein verwertbares HTML liefert.
Wie schnell löst CaptchaAI eine Turnstile-Abfrage?
In der Regel in unter 10 Sekunden, mit hoher Erfolgsquote auf den unterstützten Typen. Planen Sie trotzdem ein Timeout ein – das Beispiel oben bricht nach 30 Abfragen ab.
Kann ich mehrere Turnstile-Abfragen parallel lösen?
Ja. Die Obergrenze ist die Anzahl der Threads in Ihrem Tarif, nicht eine Zahl an Lösungen: Fünf Threads bedeuten fünf gleichzeitig laufende Abfragen. Mehr parallele CI-Jobs brauchen entsprechend mehr Threads.
Fazit
Drei Handgriffe führen in Node.js von der Turnstile-Abfrage zum abgesendeten Formular: Sitekey mit 0x-Präfix auslesen, Token über method=turnstile bei CaptchaAI anfordern, als cf-turnstile-response mitsenden. Wer dazu action und cData beachtet und spät löst, hat den Ablauf stabil im Griff.