How to handle CAPTCHA in Playwright.
A Playwright script that hits a CAPTCHA has three things it can do: work out which one it is, stop triggering it so often, or get a valid token from outside the browser and hand it to the form. Which of those is available depends entirely on which CAPTCHA the page runs, so start by identifying it. Everything after that is different for hCaptcha, reCAPTCHA, and Turnstile.
First, identify what you are facing
People describe all of these as “the captcha”, but they are five different products from four different vendors, with different response fields and different answers to “can I automate past this”. Playwright can tell them apart in a couple of selector checks. Each one leaves a distinctive container element, a distinctive hidden response field, and an iframe from its vendor’s domain.
| CAPTCHA | What Playwright can see in the DOM | Solved by NoneCap |
|---|---|---|
| hCaptcha | .h-captcha[data-sitekey], a textarea[name="h-captcha-response"] in the form, and an iframe served from hcaptcha.com | Yes: checkbox, invisible, and enterprise rqdata |
| reCAPTCHA v2 | .g-recaptcha[data-sitekey], a textarea[name="g-recaptcha-response"], and an iframe whose src contains recaptcha/api2/anchor | No, roadmap only |
| reCAPTCHA v3 | No widget at all. window.grecaptcha.execute is a function and a floating badge is usually pinned to a page corner | No, roadmap only |
| Cloudflare Turnstile | .cf-turnstile, an input[name="cf-turnstile-response"], and an iframe from challenges.cloudflare.com | No, roadmap only |
| FunCaptcha (Arkose) | A container such as #arkose or #funcaptcha, an iframe from arkoselabs.com, and an fc-token value | No, roadmap only |
Two things trip people up in that table. reCAPTCHA v3 has no widget to
find, so a DOM query returns nothing even though the page is protected;
you have to ask the page whether grecaptcha.execute exists.
And hCaptcha in drop-in mode reuses reCAPTCHA’s old
g-recaptcha-response field name, so finding that field is
not proof you are looking at reCAPTCHA. Check the container class and
the iframe origin before you conclude anything.
from playwright.sync_api import Page, sync_playwright
WIDGETS = [
("hcaptcha", ".h-captcha[data-sitekey], iframe[src*='hcaptcha.com']"),
("recaptcha_v2", ".g-recaptcha[data-sitekey], iframe[src*='recaptcha/api2/anchor']"),
("turnstile", ".cf-turnstile, iframe[src*='challenges.cloudflare.com']"),
("funcaptcha", "#arkose, #funcaptcha, iframe[src*='arkoselabs.com']"),
]
def detect_captcha(page: Page) -> str | None:
for name, selector in WIDGETS:
if page.locator(selector).count() > 0:
return name
# v3 renders no widget, so ask the page instead of the DOM.
has_v3 = page.evaluate(
"() => typeof window.grecaptcha?.execute === 'function'"
" && !document.querySelector('.g-recaptcha[data-sitekey]')"
)
return "recaptcha_v3" if has_v3 else None
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://target.example/signup")
page.wait_for_timeout(1500) # widgets load in their own iframes, give them a beat
print(detect_captcha(page))
browser.close()
Give the page a moment before you run this. Every one of these widgets
loads asynchronously into its own iframe, so a check that fires the
instant goto resolves will report nothing on a page that
very much has a captcha. Waiting for a specific selector is tidier than
a fixed timeout when you already know what you are expecting.
Second, stop triggering it so often
Challenge frequency is not fixed. hCaptcha, reCAPTCHA, and Turnstile all score a request before deciding whether to interrupt it, and the same script can go from a challenge on every run to a challenge on one run in twenty depending on how it connects and how it behaves. The inputs that move the number, roughly in order of weight:
- IP reputation dominates everything else. Traffic from AWS, Hetzner, DigitalOcean, and every other hosting range is challenged almost by default, because almost nobody browses from a datacenter. Running the same script from a residential connection, or through a residential proxy that is not heavily resold, is the single biggest change available to you.
-
A consistent session matters next. The scoring looks for
agreement between the parts of your setup: user agent,
Accept-Language, timezone, screen size, and the platform they all claim. A Linux container announcing a macOS user agent in a US timezone with a German locale is a mismatch that a real browser does not produce. In Playwright, set these together on the context rather than one at a time. -
Persistent state helps. A fresh profile with no cookies and
no history looks colder than a lived-in one. Use
launch_persistent_context, or save and reloadstorage_state, so a run that already cleared a challenge does not start from zero on the next page. - Velocity counts against you. Risk compounds inside a session. Ten signups a minute from one address scores worse than one, and hammering the same form after a failure makes the next attempt harder rather than easier. Back off on failure instead of retrying immediately.
- Headful beats headless. Chromium’s headless mode still differs from headful in ways detectors have learned to read. Running headful under a virtual display costs you some resources and removes a signal.
Stealth tooling belongs in this section rather than the next one.
playwright-stealth and its cousins patch
navigator.webdriver, the plugin and language arrays, and
various property mismatches that give a headless browser away. They
do reduce how often crude checks fire. What they do not do is
produce a token: nothing you patch in the page makes the widget hand you
a valid response string, and none of it touches your IP reputation.
No configuration reliably prevents challenges on a site you do not control. You do not control the heaviest input, your address’s reputation, and you do not control the site’s own settings, which can force a challenge on every visitor regardless of score. Better infrastructure lowers the rate. Nothing pins it to zero, so a pipeline that assumes zero breaks the first time a threshold changes.
The details behind each of those signals, and what to change first, are in why am I getting hCaptcha challenges.
Third, solve hCaptcha out of band
When the challenge appears anyway, the durable pattern is to stop
fighting it inside the browser you are automating. The form only
checks that a valid token is in the response field when you submit; it has
no way to know how the token was produced. So you read the sitekey, get a
token from an API, put it in the field, and carry on. NoneCap solves the
challenge off your machine and returns a real P1_ token; your
Playwright script never touches the puzzle UI.
The flow is four steps, and it is the same on every hCaptcha page:
- 1. Read
data-sitekeyoff the widget, and take the page URL frompage.url. - 2. Solve with
POST https://api.nonecap.com/v1/solves, sending{ type: "hcaptcha", sitekey, url }. - 3. Inject the returned
tokenintotextarea[name="h-captcha-response"], and call thedata-callbackfunction if the widget has one. - 4. Submit the form however your page submits it.
import os
import time
import requests
from playwright.sync_api import sync_playwright
API = "https://api.nonecap.com/v1/solves"
HEADERS = {"Authorization": f"Bearer {os.environ['NONECAP_KEY']}"}
def solve_hcaptcha(sitekey: str, url: str) -> str:
r = requests.post(
API,
headers=HEADERS,
params={"wait": 90},
json={"type": "hcaptcha", "sitekey": sitekey, "url": url},
timeout=120,
)
r.raise_for_status()
data = r.json()
# ?wait is a long poll with a deadline: the solve can still be in flight
# when it returns, so keep polling until the status is terminal.
while data["status"] in ("pending", "solving"):
time.sleep(2)
data = requests.get(f"{API}/{data['id']}", headers=HEADERS, timeout=30).json()
if data["status"] != "solved":
raise RuntimeError(f"solve {data['status']}: {data.get('error')}")
return data["token"]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://target.example/signup")
# 1. Read the sitekey off the rendered widget.
sitekey = page.locator("[data-sitekey]").first.get_attribute("data-sitekey")
# 2. Get a real token out of band.
token = solve_hcaptcha(sitekey, page.url)
# 3. Put the token where the form expects it, then fire the callback.
page.evaluate(
"""(token) => {
for (const name of ['h-captcha-response', 'g-recaptcha-response']) {
const el = document.querySelector(`textarea[name="${name}"]`);
if (el) el.value = token;
}
const cb = document.querySelector('[data-callback]')?.getAttribute('data-callback');
if (cb && typeof window[cb] === 'function') window[cb](token);
}""",
token,
)
# 4. Submit the form the way the page does.
page.click("button[type=submit]")
page.wait_for_load_state("networkidle")
browser.close()
The ?wait=90 parameter is a long poll with a deadline, not a
guarantee. If the solve is still running when the deadline passes you get
a 202 back with a null token and a status of
pending or solving, which is why the helper
above keeps polling GET /v1/solves/{id} until the status
is terminal. Skipping that loop is the most common way this integration
breaks under load: it works in testing and then returns
None on the busy run.
Step 3 has one wrinkle worth knowing. An invisible widget does not submit
on a value change, it submits from its callback, so setting the textarea
alone does nothing visible and the form sits there. Read the function
name out of the data-callback attribute and call
window[name](token), as the snippet does. The
Playwright integration guide has the
full Python and Node versions plus the enterprise
rqdata variant, where each challenge carries a fresh blob you
have to capture and forward with the solve.
reCAPTCHA, Turnstile, and FunCaptcha
The injection pattern generalises: every one of these products drops its
result into a hidden field that the form reads on submit, so
mechanically the shape is the same, only the field name changes
(g-recaptcha-response, cf-turnstile-response,
the Arkose fc-token). What does not generalise is the hard
part, which is getting a token the vendor will accept.
NoneCap solves hCaptcha only. Regular checkbox,
invisible, and enterprise rqdata sitekeys. reCAPTCHA,
Turnstile, and FunCaptcha are on the roadmap and are not live, so pointing
one of those workloads at the API will not work today. We would rather say
so here than have you find out from a failed run.
What you do instead depends on which of them you are looking at. Turnstile is the mildest of the three: it is an interstitial far more often than a puzzle, and a residential address plus a real headful browser and persisted cookies is frequently enough for it to pass on its own. Try the previous section before you try anything else. reCAPTCHA v3 never interrupts anyone; it returns a score to the site’s backend, so there is nothing to solve and nothing to inject. If you are being blocked by v3, you are being blocked by your score, and the only lever is the one in the previous section. reCAPTCHA v2 and FunCaptcha do need a token you cannot mint locally, so a service that covers those types is the answer, and today that is not us.
Testing without burning real challenges
One more thing worth setting up before any of this reaches CI. If your own application uses hCaptcha and you are writing Playwright tests against it, do not point those tests at your production sitekey. Every run then depends on a live third-party service, adds latency, and either costs you solves or trains a rate limiter on your build server’s address.
hCaptcha publishes an official test sitekey,
10000000-ffff-ffff-ffff-000000000001, which always passes and
always emits the same dummy token. Load it by environment so production
keeps your real key, and your suite exercises the whole form flow with no
challenge and no charge. The keys, the matching test secret, and the
not-using-dummy-passcode error you get from mixing test and
real values are in
hCaptcha test sitekeys.
Reserve real solves for what they are for: automating a site you do not own, where the challenge is a step in the flow like a redirect or a login wall. Detect it, read the sitekey, fetch the token, inject, submit. The API reference documents the request and response objects, and the same four steps work in Puppeteer and Selenium with different method names.
Last updated August 2026.
Frequently asked
Can Playwright solve CAPTCHA by itself?
page.click does not help either, because the widget scores the whole session rather than the click. Your two real options are to stop triggering the challenge and, when it appears anyway, to fetch a valid token from somewhere else and inject it.Does playwright-stealth bypass hCaptcha or reCAPTCHA?
navigator.webdriver and patches the header and property mismatches that give a headless browser away. That is worth having, and it clears the crudest checks. It does not produce a valid token, and it does not change your IP reputation, which is the heaviest input to the score. Treat stealth as one input to the challenge rate, never as a solve.How do I detect which CAPTCHA a page uses in Playwright?
.h-captcha[data-sitekey] for hCaptcha, .g-recaptcha[data-sitekey] for reCAPTCHA v2, .cf-turnstile for Turnstile, and an arkoselabs.com iframe for FunCaptcha. reCAPTCHA v3 renders nothing, so test for it in JavaScript with typeof window.grecaptcha?.execute === "function". There is a ready-made detect_captcha(page) helper above.