Handling Turnstile in Playwright and Puppeteer
Turnstile stalls under headless automation. Two ways to get past it in Playwright and Puppeteer, including injecting a solved token into the form.
Your Playwright script loads the page, the Turnstile widget sits there spinning, and it never flips to the green check. So the form won't submit, and your run dies at the gate. The frustrating part is that the same script works when you drive it by hand. That gap is the whole problem, and there are two clean ways to close it.
Either you harden the browser enough that Turnstile trusts it and solves on its own, or you get the token out-of-band and inject it into the page. The second is more reliable under automation, because you stop fighting fingerprint detection and just hand the form what it needs.
Why the widget stalls under automation
Turnstile scores the browser in the background. Headless Chromium leaks signals that a normal browser doesn't: automation flags, a thin canvas and WebGL fingerprint, timing that's too clean. When the score comes back low, the widget quietly refuses to issue a token. No error, just an endless spinner. Puppeteer has the same issue for the same reasons.
You can chase this with stealth plugins, and sometimes it's enough. But you're now maintaining an anti-detection layer that breaks every time Cloudflare adjusts its model. If you only need the token, there's less moving machinery in getting it directly.
Inject a solved token into Playwright
Solve with the API, then drop the token into the hidden cf-turnstile-response field the form reads on submit:
import requests
from playwright.sync_api import sync_playwright
URL = "https://target.com/login"
SITEKEY = "0x4AAAAAAAxxxxxxxx"
PROXY = "http://user:pass@ip:port"
def solve_token():
return requests.post(
"https://api.peak.fo/solve",
headers={"X-API-Key": "pk_your_api_key"},
json={"task_type": "turnstiletask", "url": URL, "sitekey": SITEKEY, "proxy": PROXY},
timeout=30,
).json()["data"]["token"]
token = solve_token() # ~1 to 1.5s on a typical target
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(URL)
page.evaluate(
"""(token) => {
let el = document.querySelector('[name="cf-turnstile-response"]');
if (!el) {
el = document.createElement('input');
el.type = 'hidden';
el.name = 'cf-turnstile-response';
(document.forms[0] || document.body).appendChild(el);
}
el.value = token;
}""",
token,
)
page.fill('input[name="email"]', "you@example.com")
page.fill('input[name="password"]', "...")
page.click('button[type="submit"]')The form posts the token exactly as a browser-solved one would, because it is one. For the details of what that token is and why it expires, see the cf-turnstile-response token, explained.
The same idea in Puppeteer
// Node + Puppeteer
const token = await solveToken(); // POST to api.peak.fo/solve, read data.token
await page.goto(URL);
await page.evaluate((token) => {
let el = document.querySelector('[name="cf-turnstile-response"]');
if (!el) {
el = document.createElement('input');
el.type = 'hidden';
el.name = 'cf-turnstile-response';
(document.forms[0] || document.body).appendChild(el);
}
el.value = token;
}, token);
await page.click('button[type="submit"]');Two things that trip people up
Callbacks. Some pages don't read the field on submit; they act on a JavaScript callback set with data-callback. If the button stays disabled after you set the field, find the callback name and call it: page.evaluate((t) => window.myTurnstileCb(t), token).
Timing. Turnstile tokens are single-use and expire in roughly 300 seconds. Solve right before you submit, not at the top of a long script, or the token goes stale before the form sees it.
If you're weighing whether to keep the browser at all, solving API vs headless browser lays out when each approach is the right call. The end-to-end flow lives in the Turnstile solving guide.
FAQ
Why won't Turnstile solve in headless Playwright?
Headless Chromium leaks automation signals and a weak fingerprint, so Turnstile scores the browser low and never issues a token. Either harden the browser against detection or solve the token out-of-band and inject it into the cf-turnstile-response field.
Do I need stealth plugins?
Not if you inject a solved token. Stealth helps when you want the widget itself to pass, but it needs ongoing maintenance as Cloudflare changes. Injecting a token sidesteps the fingerprint fight entirely.
How do I handle a Turnstile callback?
If the page uses data-callback, setting the hidden field may not be enough. Read the callback name from the widget config and call it with the token via page.evaluate. See pricing to start.
Automating past Turnstile? Grab a key free at peak.fo.