guides · selenium + turnstile

Selenium and Cloudflare Turnstile in Python

Turnstile writes its token into a hidden input named cf-turnstile-response. In Selenium you either wait for that field to fill, or set it yourself with execute_script. Build both paths against Cloudflare's documented test sitekeys first, so you know the plumbing works before a real widget is involved.

Start with the test sitekeys

Cloudflare publishes dummy sitekeys with fixed outcomes. They render a real widget, run no real challenge, and return a token formatted XXXX.DUMMY.TOKEN.XXXX. Use them to write and debug your Selenium code, because every failure you see is then your code, not the challenge.

SitekeyOutcomeWidget
1x00000000000000000000AAAlways passesVisible
2x00000000000000000000ABAlways failsVisible
1x00000000000000000000BBAlways passesInvisible
2x00000000000000000000BBAlways failsInvisible
3x00000000000000000000FFForces an interactive challengeVisible

There are matching test secret keys for the server side. A production secret key rejects the dummy token, so pair them:

  • 1x0000000000000000000000000000000AA — always passes validation
  • 2x0000000000000000000000000000000AA — always fails validation
  • 3x0000000000000000000000000000000AA — returns the token-already-spent error, timeout-or-duplicate

The 3x…FF sitekey is the one worth spending time on. It forces the interactive path, which is the case your automation is most likely to stall on.

A local page to drive

Save this as turnstile-test.html and serve it over HTTP — python -m http.server 8000 in the same directory. Serve it rather than opening it from disk, because the Turnstile script needs a real HTTP origin.

html
<!doctype html>
<html lang="en">
  <head><meta charset="utf-8"><title>Turnstile test page</title></head>
  <body>
    <form id="demo" action="/submit" method="POST">
      <div class="cf-turnstile"
           data-sitekey="1x00000000000000000000AA"
           data-callback="onTurnstileToken"></div>
      <button type="submit" id="go" disabled>Submit</button>
    </form>
    <script>
      function onTurnstileToken(token) {
        console.log("turnstile token:", token);
        document.getElementById("go").disabled = false;
      }
    </script>
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  </body>
</html>

Two details from this page matter later. The widget sits inside a <form>, so Turnstile adds the hidden cf-turnstile-response input automatically. And the submit button is gated on data-callback firing — a pattern common on real sign-in forms, and the reason writing a token into the field is sometimes not enough on its own.

Wait for the token the widget produces

The reliable signal that Turnstile finished is the hidden field holding a non-empty value. Wait on that, not on a timer, and not on the widget's visual state.

python
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

TOKEN_FIELD = (By.CSS_SELECTOR, "input[name='cf-turnstile-response']")
WIDGET_FRAME = (By.CSS_SELECTOR, "iframe[src*='challenges.cloudflare.com']")


def build_driver(headless=True):
    options = webdriver.ChromeOptions()
    if headless:
        options.add_argument("--headless=new")
    options.add_argument("--window-size=1920,1080")
    return webdriver.Chrome(options=options)


def wait_for_widget_token(driver, timeout=30):
    """Block until Turnstile fills its hidden input, then return the token."""
    WebDriverWait(driver, timeout).until(
        EC.presence_of_element_located(WIDGET_FRAME)
    )
    return WebDriverWait(driver, timeout).until(
        lambda d: d.find_element(*TOKEN_FIELD).get_attribute("value") or False
    )


driver = build_driver()
try:
    driver.get("http://localhost:8000/turnstile-test.html")
    token = wait_for_widget_token(driver)
    print("dummy" if token.startswith("XXXX.DUMMY.TOKEN") else "real", token)
finally:
    driver.quit()

The lambda re-locates the element on every poll instead of holding a reference. Turnstile can reset and re-render its widget, which invalidates a cached element. WebDriverWait ignores NoSuchElementException by default, so the lambda is safe before the field exists, and or False keeps an empty string from counting as success.

Use presence_of_element_located rather than visibility_of_element_located for the token field. It is a hidden input; it is never visible.

Do not add an implicit wait alongside this. Selenium's documentation is explicit that mixing implicit and explicit waits produces unpredictable timeouts.

Inject a token with execute_script

When the token comes from somewhere other than the browser, you write it into the page yourself. Setting .value from JavaScript fires no events, so dispatch input and change, and call the widget's callback if the page declared one.

python
INJECT_TOKEN = """
var name = arguments[0], token = arguments[1];
var field = document.querySelector('input[name="' + name + '"]');
if (!field) {
  var form = document.querySelector('form');
  if (!form) { throw new Error('no form to attach ' + name + ' to'); }
  field = document.createElement('input');
  field.type = 'hidden';
  field.name = name;
  form.appendChild(field);
}
field.value = token;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));

var holder = document.querySelector('[data-callback]');
var cb = holder && holder.getAttribute('data-callback');
if (cb && typeof window[cb] === 'function') { window[cb](token); }

return field.value;
"""


def inject_token(driver, token, field_name="cf-turnstile-response"):
    return driver.execute_script(INJECT_TOKEN, token and field_name, token)

Two failure modes account for most of the trouble here. If the widget is not inside a <form>, Turnstile never adds the hidden input and the site is reading the token from its callback instead — creating the field changes nothing, and you have to invoke the callback. And if the page uses explicit rendering, there may be no data-sitekey attribute in the DOM to read the sitekey from; check the turnstile.render call in the page's own JavaScript.

Move quickly once you hold a token. A Turnstile token is valid for 300 seconds and can be validated once. Submit late or twice and siteverify returns timeout-or-duplicate.

Headless versus headed

Selenium's Chrome documentation shows the current headless flag as --headless=new; the older --headless selects the legacy implementation. Both run the same Turnstile script, but the environment around it differs in ways worth controlling.

  • **Viewport.** Headless Chrome starts at its own default size, not a maximised window. Set --window-size explicitly, because the flexible widget size lays out against its container width.
  • **Profile.** A fresh headless run carries no cookies or storage. Pass --user-data-dir if you want state to survive between runs.
  • **Rendering.** Fonts, GPU and device-pixel-ratio differ from a desktop session. This affects screenshots and layout assertions, not the challenge protocol itself.
  • **Measure, do not assume.** driver.execute_script("return navigator.userAgent") and return [screen.width, screen.height, devicePixelRatio] tell you exactly what your build reports in each mode. Run them in both before theorising.

Cloudflare does not publish the client-side signals a managed widget evaluates, so no one can honestly tell you that headless always passes or always fails. Run the 3x…FF test sitekey in both modes to confirm your waits and clicks work, then measure the real widget on your own property.

A factual note on webdriver detection

The W3C WebDriver specification defines a webdriver-active flag, exposed to pages as navigator.webdriver. Any conforming Selenium session sets it to true. Chrome launched by ChromeDriver also carries the --enable-automation switch. Both are observable from ordinary JavaScript:

python
print(driver.execute_script("return navigator.webdriver"))  # True under Selenium

That is the whole of it as a fact. This guide does not cover suppressing those signals. If a widget on your own property will not issue a token inside your automation environment, the supported answer is to obtain the token outside the browser and inject it, which is the next section.

Getting a token from SolveGate

Point this at properties you own or are authorised to test — your staging environments, your CI, your uptime checks, your own anti-bot configuration. That is the boundary.

SolveGate solves Cloudflare Turnstile (managed, non-interactive and invisible) and Turnstile WAF challenge pages. You POST the gate, the sitekey and the page url, and get a token back. It does not solve reCAPTCHA, hCaptcha, GeeTest, FunCaptcha or AWS WAF.

python
# pip install selenium solvegate
import os

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
from solvegate import SolveGate

TARGET = "https://staging.example.com/login"  # a property you own

driver = build_driver(headless=True)        # from the section above
sg = SolveGate(os.environ["SOLVEGATE_KEY"])

try:
    driver.get(TARGET)

    holder = WebDriverWait(driver, 20).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "[data-sitekey]"))
    )
    sitekey = holder.get_attribute("data-sitekey")

    res = sg.solve(gate="turnstile", sitekey=sitekey, url=TARGET)
    if res.status != "solved":
        raise RuntimeError(f"solve {res.status}")
    if res.token.startswith("SANDBOX."):
        raise RuntimeError("sandbox key: this token will not clear a real widget")

    inject_token(driver, res.token)         # from the section above
    driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

    WebDriverWait(driver, 20).until(EC.url_contains("/dashboard"))
finally:
    driver.quit()

A sk_test_ key returns a deterministic token prefixed SANDBOX. and costs nothing — the sandbox analogue of Cloudflare's XXXX.DUMMY.TOKEN.XXXX. It exercises your Selenium code end to end without clearing a real gate, and the API marks it with a mode of sandbox so you can assert on it in CI. Switch to a live key only when the injection path already works.

For long pages or slow targets, pass async_=True and poll GET /v1/solve/{id} with sg.wait(id). Polling is free.

Let the widget runInject a token
Where the challenge runsIn the Selenium browser, in a cross-origin iframeOutside your browser, over the API
Selenium codeWait for cf-turnstile-response to fillexecute_script writes the field
Typical wall clockDepends on the browser and widget modeToken usually returned in under 1.5s
Common failureThe widget never issues a token in your environmentThe page gates submit on a callback you did not fire
Sitekey neededNo — it is already on the pageYes, plus the exact page URL
CostNonePrepaid credits, $0.40 per 1,000 down to $0.075 at volume; failed solves are never billed

First 1,000 solves are free. SDKs are published as solvegate on npm and PyPI (Node 18+, Python 3.9+).

Common questions

You are using one of Cloudflare's test sitekeys. That token is a placeholder, and a production secret key rejects it. Validate it with a matching test secret key, or switch the page to your real sitekey.

The widget renders inside a cross-origin iframe served from challenges.cloudflare.com. You can reach it with switch_to.frame, but its internal DOM is not a documented or stable API, so selectors written against it can break without notice. In managed mode Cloudflare decides whether an interaction is required at all; the 3x00000000000000000000FF test sitekey forces that path so you can exercise it deliberately.

Cloudflare does not publish which client-side signals a managed widget evaluates, so this cannot be answered generally. What you can do is measure: run the same script headed and with --headless=new against your own property and compare outcomes, with the window size fixed in both.

A Turnstile token is valid for 300 seconds and can be validated only once. Either you submitted it after it expired, or the same token was already sent to siteverify. Fetch a fresh token per submission.

The one the page uses. Read it from the data-sitekey attribute on the widget container, or from the turnstile.render call if the site renders explicitly. Pass the exact page URL alongside it — the sitekey and URL are both part of what is being solved.

That is outside what this guide covers. If a Turnstile widget on a property you own will not issue a token inside your automation environment, get the token from an API and inject it with execute_script rather than modifying the browser's reported state.

Related

More in Guides

Automating a gate you own or are authorised to test?

// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free