Explainers

Guide de détection d’implémentation de Cloudflare Turnstile

Un widget Cloudflare Turnstile laisse toujours les mêmes traces sur une page :

  • le script challenges.cloudflare.com/turnstile ;
  • un conteneur portant la classe cf-turnstile ;
  • un attribut data-sitekey.

Détecter Turnstile, c'est retrouver ces marqueurs puis en extraire la clé de site (sitekey) et le mode du widget — les deux seules informations dont l'API a besoin. La difficulté : ils n'apparaissent pas toujours dans le HTML initial. Selon l'intégration, la détection va d'une simple lecture de la source à l'analyse du JavaScript exécuté.


Comment les sites intègrent Turnstile

Turnstile s'intègre de trois façons, et chacune appelle une approche différente :

  • HTML implicite<div class="cf-turnstile" data-sitekey="..."> dans la source ; détection facile.
  • JavaScript expliciteturnstile.render() appelé dans un script ; il faut analyser le JS.
  • Chargement dynamique — widget injecté après une action ou un XHR ; exécution du JS obligatoire.

Cas concret : un pipeline de surveillance hébergé sur OVHcloud parcourt des centaines de pages de connexion. Le même code doit reconnaître, page par page, si le formulaire est protégé par Turnstile, par reCAPTCHA ou par rien — d'où l'intérêt d'une détection indépendante de l'intégration. Si vous collectez des données au passage, limitez les données personnelles conservées, conformément à vos obligations RGPD.


Méthode 1 — détecter Turnstile dans le HTML statique

L'intégration la plus simple s'appuie sur la classe cf-turnstile et l'attribut data-sitekey ; une requête HTTP et une regex suffisent :

import re
import requests

def detect_turnstile_html(url):
    """Detect Turnstile from static HTML."""
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                      "AppleWebKit/537.36 Chrome/120.0.0.0",
        "Accept": "text/html,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }

    response = requests.get(url, headers=headers, timeout=15)
    html = response.text

    result = {
        "turnstile_found": False,
        "sitekey": None,
        "mode": None,
        "theme": None,
        "action": None,
        "script_loaded": False,
    }

    # Check for Turnstile script
    if "challenges.cloudflare.com/turnstile" in html:
        result["script_loaded"] = True

    # Check for widget container
    if "cf-turnstile" in html:
        result["turnstile_found"] = True

        # Extract sitekey
        sitekey_match = re.search(
            r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', html
        )
        if sitekey_match:
            result["sitekey"] = sitekey_match.group(1)

        # Extract mode
        if 'data-size="invisible"' in html:
            result["mode"] = "invisible"
        elif 'data-appearance="interaction-only"' in html:
            result["mode"] = "non-interactive"
        else:
            result["mode"] = "managed"

        # Extract theme
        theme_match = re.search(r'data-theme=["\'](\w+)["\']', html)
        if theme_match:
            result["theme"] = theme_match.group(1)

        # Extract action
        action_match = re.search(r'data-action=["\']([^"\']+)["\']', html)
        if action_match:
            result["action"] = action_match.group(1)

    return result


# Usage
info = detect_turnstile_html("https://example.com/login")
if info["turnstile_found"]:
    print(f"Sitekey: {info['sitekey']}")
    print(f"Mode: {info['mode']}")

Deux garde-fous :

  • exigez le script challenges.cloudflare.com/turnstile (le conteneur seul ne suffit pas) ;
  • écartez tout data-sitekey vide laissé par un gabarit.

Méthode 2 — repérer l'appel turnstile.render() en JavaScript

Certains sites ne posent aucun attribut HTML et construisent le widget avec turnstile.render(). La clé de site vit alors dans l'objet de configuration passé à la fonction :

import re

def detect_turnstile_js_api(html):
    """Detect Turnstile from JavaScript render calls."""
    patterns = [
        # turnstile.render('#element', {sitekey: '...'})
        r"turnstile\.render\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*\{([^}]+)\}",
        # turnstile.render(element, {sitekey: '...'})
        r"turnstile\.render\s*\([^,]+,\s*\{([^}]+)\}",
    ]

    for pattern in patterns:
        match = re.search(pattern, html, re.DOTALL)
        if match:
            config_text = match.group(match.lastindex)

            # Extract sitekey from config object
            sitekey_match = re.search(
                r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]", config_text
            )
            # Extract callback
            callback_match = re.search(
                r"callback\s*:\s*(\w+|function)", config_text
            )
            # Extract action
            action_match = re.search(
                r"action\s*:\s*['\"]([^'\"]+)['\"]", config_text
            )
            # Extract appearance
            appearance_match = re.search(
                r"appearance\s*:\s*['\"]([^'\"]+)['\"]", config_text
            )

            return {
                "found": True,
                "method": "javascript_api",
                "sitekey": sitekey_match.group(1) if sitekey_match else None,
                "callback": callback_match.group(1) if callback_match else None,
                "action": action_match.group(1) if action_match else None,
                "appearance": appearance_match.group(1) if appearance_match else None,
            }

    return {"found": False, "method": None}

Méthode 3 — capturer un widget chargé dynamiquement (Selenium/Puppeteer)

Quand Turnstile s'injecte après une interaction ou un appel réseau, le HTML statique ne montre rien. Laissez un navigateur exécuter le JavaScript, puis inspectez le DOM rendu.

En Python avec Selenium

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import re

def detect_turnstile_dynamic(url):
    """Detect dynamically loaded Turnstile using Selenium."""
    options = webdriver.ChromeOptions()
    options.add_argument("--disable-blink-features=AutomationControlled")
    driver = webdriver.Chrome(options=options)

    try:
        driver.get(url)

        # Wait for page to fully load
        WebDriverWait(driver, 10).until(
            lambda d: d.execute_script("return document.readyState") == "complete"
        )

        result = {
            "turnstile_found": False,
            "sitekey": None,
            "iframe_present": False,
            "response_field": False,
        }

        # Check for Turnstile iframe
        iframes = driver.find_elements(By.CSS_SELECTOR, "iframe[src*='challenges.cloudflare.com']")
        if iframes:
            result["turnstile_found"] = True
            result["iframe_present"] = True

        # Check for cf-turnstile container
        containers = driver.find_elements(By.CSS_SELECTOR, ".cf-turnstile, [data-sitekey]")
        for container in containers:
            sitekey = container.get_attribute("data-sitekey")
            if sitekey:
                result["turnstile_found"] = True
                result["sitekey"] = sitekey

        # Check for hidden response field
        response_fields = driver.find_elements(
            By.CSS_SELECTOR, "[name='cf-turnstile-response'], [name='g-recaptcha-response']"
        )
        if response_fields:
            result["response_field"] = True

        # Check page source for JS API render
        page_source = driver.page_source
        js_match = re.search(
            r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]", page_source
        )
        if js_match and not result["sitekey"]:
            result["sitekey"] = js_match.group(1)
            result["turnstile_found"] = True

        return result

    finally:
        driver.quit()

En Node.js avec Puppeteer

const puppeteer = require("puppeteer");

async function detectTurnstileDynamic(url) {
  const browser = await puppeteer.launch({
    headless: "new",
    args: ["--disable-blink-features=AutomationControlled"],
  });

  const page = await browser.newPage();

  const result = {
    turnstileFound: false,
    sitekey: null,
    iframePresent: false,
    responseField: false,
    scriptUrl: null,
  };

  // Monitor network for Turnstile script
  page.on("response", (response) => {
    if (response.url().includes("challenges.cloudflare.com/turnstile")) {
      result.scriptUrl = response.url();
    }
  });

  await page.goto(url, { waitUntil: "networkidle2" });

  // Check for Turnstile container
  const sitekey = await page.evaluate(() => {
    const el = document.querySelector(
      ".cf-turnstile, [data-sitekey]"
    );
    return el ? el.getAttribute("data-sitekey") : null;
  });

  if (sitekey) {
    result.turnstileFound = true;
    result.sitekey = sitekey;
  }

  // Check for Turnstile iframe
  const iframes = await page.$$("iframe[src*='challenges.cloudflare.com']");
  if (iframes.length > 0) {
    result.turnstileFound = true;
    result.iframePresent = true;
  }

  // Check for response field
  const responseField = await page.$(
    "[name='cf-turnstile-response']"
  );
  result.responseField = !!responseField;

  await browser.close();
  return result;
}

detectTurnstileDynamic("https://example.com/login").then(console.log);

Côté fiabilité, deux réflexes paient :

  • attendez le rendu complet de la page avant de lire le DOM ;
  • gardez l'iframe challenges.cloudflare.com comme signal de secours.

Une classe de détection qui couvre les trois cas

En production, vous ignorez l'intégration de chaque page. Regroupez les trois méthodes dans une classe : une URL en entrée, la clé, le mode et le type en sortie.

import re
import requests

class TurnstileDetector:
    """Detect Cloudflare Turnstile across all implementation methods."""

    TURNSTILE_SCRIPT = "challenges.cloudflare.com/turnstile"
    SITEKEY_PATTERNS = [
        r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']',
        r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
        r"siteKey\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
        r"TURNSTILE_SITE_KEY\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
    ]

    def __init__(self, url, html=None):
        self.url = url
        self.html = html
        if not self.html:
            self._fetch()

    def _fetch(self):
        headers = {
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                          "AppleWebKit/537.36 Chrome/120.0.0.0",
            "Accept": "text/html,*/*;q=0.8",
            "Accept-Language": "en-US,en;q=0.9",
        }
        response = requests.get(self.url, headers=headers, timeout=15)
        self.html = response.text

    def detect(self):
        """Run all detection methods and return results."""
        return {
            "url": self.url,
            "turnstile_present": self.has_turnstile(),
            "sitekey": self.extract_sitekey(),
            "mode": self.detect_mode(),
            "implementation": self.detect_implementation(),
            "script_loaded": self.has_script(),
            "response_field": self.has_response_field(),
            "action": self.extract_action(),
            "theme": self.extract_theme(),
        }

    def has_turnstile(self):
        return (
            self.has_script()
            or "cf-turnstile" in self.html
            or self.extract_sitekey() is not None
        )

    def has_script(self):
        return self.TURNSTILE_SCRIPT in self.html

    def has_response_field(self):
        return "cf-turnstile-response" in self.html

    def extract_sitekey(self):
        for pattern in self.SITEKEY_PATTERNS:
            match = re.search(pattern, self.html)
            if match:
                return match.group(1)
        return None

    def detect_mode(self):
        if 'data-size="invisible"' in self.html or "size: 'invisible'" in self.html:
            return "invisible"
        if 'data-appearance="interaction-only"' in self.html:
            return "non-interactive"
        if "cf-turnstile" in self.html:
            return "managed"
        return "unknown"

    def detect_implementation(self):
        if "cf-turnstile" in self.html and re.search(r"data-sitekey=", self.html):
            return "html_implicit"
        if "turnstile.render" in self.html:
            return "javascript_explicit"
        if self.has_script() and not "cf-turnstile" in self.html:
            return "dynamic_loading"
        return "unknown"

    def extract_action(self):
        match = re.search(r'data-action=["\']([^"\']+)["\']', self.html)
        if match:
            return match.group(1)
        match = re.search(r"action\s*:\s*['\"]([^'\"]+)['\"]", self.html)
        return match.group(1) if match else None

    def extract_theme(self):
        match = re.search(r'data-theme=["\'](\w+)["\']', self.html)
        return match.group(1) if match else "auto"


# Usage
detector = TurnstileDetector("https://example.com/login")
info = detector.detect()

if info["turnstile_present"]:
    print(f"Sitekey: {info['sitekey']}")
    print(f"Mode: {info['mode']}")
    print(f"Implementation: {info['implementation']}")

Résoudre le défi une fois la clé de site extraite

La clé de site et l'URL en main, la résolution tient en un aller-retour avec l'API CaptchaAI : envoyez la tâche à in.php, puis interrogez res.php jusqu'au token.

import requests
import time

API_KEY = "YOUR_API_KEY"

def solve_detected_turnstile(detection_result):
    """Solve Turnstile using detection results."""
    if not detection_result["turnstile_present"]:
        raise ValueError("No Turnstile detected")

    if not detection_result["sitekey"]:
        raise ValueError("Sitekey not found — may need browser-based extraction")

    params = {
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": detection_result["sitekey"],
        "pageurl": detection_result["url"],
        "json": 1,
    }

    # Include action if present
    if detection_result.get("action"):
        params["action"] = detection_result["action"]

    submit = requests.post("https://ocr.captchaai.com/in.php", data=params)
    task_id = submit.json()["request"]

    for _ in range(60):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Turnstile solve timed out")


# Full workflow
detector = TurnstileDetector("https://example.com/signup")
info = detector.detect()

if info["turnstile_present"]:
    token = solve_detected_turnstile(info)
    print(f"Token: {token[:50]}...")

Deux points à ne pas négliger :

  • le token Turnstile est à usage unique et expire vite : soumettez-le sans tarder ;
  • transmettez action si data-action figurait sur le widget, sinon la validation peut échouer.

Dépannage

Le symptôme pointe presque toujours vers l'une de ces causes :

Symptôme Cause probable Correctif
Balise de script présente mais aucune clé de site Rendu via l'API JS avec une configuration venue d'ailleurs Vérifiez tous les fichiers JS liés et les réponses XHR
Mauvaise clé de site extraite Plusieurs widgets CAPTCHA sur la page Rapprochez chaque clé des éléments de formulaire voisins
Détection correcte mais résolution en échec Le paramètre action est exigé à la validation Ajoutez la valeur data-action à la requête de résolution
Widget absent du HTML initial Chargement dynamique après une action de l'utilisateur Passez par Selenium/Puppeteer pour un rendu complet
Champ cf-turnstile-response vide Le widget n'a pas terminé son cycle Attendez la fin du chargement du widget

Cas limites à anticiper

Au-delà des trois intégrations standard, quelques configurations demandent un traitement particulier :

  • Sitekey dans un fichier JS externe — absente du HTML : analysez les fichiers JavaScript liés.
  • Sitekey renvoyée par une API — chargée après un XHR : cherchez la clé dans les réponses JSON.
  • Plusieurs widgets Turnstile — des clés différentes : associez chaque clé au bon formulaire.
  • Turnstile dans un shadow DOM — hors des sélecteurs classiques : passez par shadowRoot.querySelector.
  • Sitekey rendue côté serveur — injectée via un gabarit : inspectez les balises <script> de configuration.
  • Turnstile derrière une authentification — invisible en public : authentifiez-vous d'abord, puis détectez.

Questions fréquentes

Les questions qui reviennent le plus souvent :

Comment distinguer Turnstile de reCAPTCHA sur une page ?

Regardez le domaine des ressources et le nom du champ caché. Turnstile charge challenges.cloudflare.com/turnstile et renseigne cf-turnstile-response ; reCAPTCHA charge google.com/recaptcha et remplit g-recaptcha-response.

Peut-on coder la clé de site en dur une fois récupérée ?

À éviter : l'opérateur peut faire tourner sa clé à tout moment, et une valeur figée finira par ne plus correspondre. Extrayez-la à chaque exécution.

Quand Selenium ou Puppeteer deviennent-ils indispensables ?

Dès que le widget n'est pas dans le HTML initial : chargement après une action, injection via XHR ou clé générée côté client. Un navigateur headless exécute le JavaScript avant la lecture du DOM.

Comment gérer plusieurs widgets Turnstile sur une même page ?

Ne prenez pas la première clé venue : repérez le formulaire soumis et prenez la clé du conteneur cf-turnstile correspondant. Une mauvaise clé produit un token refusé à la validation.


L'essentiel

Pour détecter Cloudflare Turnstile, cherchez sa balise de script, le conteneur cf-turnstile, l'attribut data-sitekey et les appels turnstile.render(). L'analyse du HTML statique suffit aux intégrations simples ; les widgets dynamiques imposent Selenium ou Puppeteer. Une fois la clé extraite, confiez la résolution au solveur Turnstile de CaptchaAI : quel que soit le mode, le traitement côté API reste identique.

Articles connexes

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