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.
| Sitekey | Outcome | Widget |
|---|---|---|
| 1x00000000000000000000AA | Always passes | Visible |
| 2x00000000000000000000AB | Always fails | Visible |
| 1x00000000000000000000BB | Always passes | Invisible |
| 2x00000000000000000000BB | Always fails | Invisible |
| 3x00000000000000000000FF | Forces an interactive challenge | Visible |
There are matching test secret keys for the server side. A production secret key rejects the dummy token, so pair them:
1x0000000000000000000000000000000AA— always passes validation2x0000000000000000000000000000000AA— always fails validation3x0000000000000000000000000000000AA— 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.
<!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.
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.
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-sizeexplicitly, because theflexiblewidget size lays out against its container width. - **Profile.** A fresh headless run carries no cookies or storage. Pass
--user-data-dirif 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")andreturn [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:
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.
# 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 run | Inject a token | |
|---|---|---|
| Where the challenge runs | In the Selenium browser, in a cross-origin iframe | Outside your browser, over the API |
| Selenium code | Wait for cf-turnstile-response to fill | execute_script writes the field |
| Typical wall clock | Depends on the browser and widget mode | Token usually returned in under 1.5s |
| Common failure | The widget never issues a token in your environment | The page gates submit on a callback you did not fire |
| Sitekey needed | No — it is already on the page | Yes, plus the exact page URL |
| Cost | None | Prepaid 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