Handle CAPTCHA in Playwright, solve hCaptcha.
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. If the page runs
hCaptcha, that last option is four steps: read the
data-sitekey off the page, ask the NoneCap hCaptcha solver
for a real token, inject it into the form's h-captcha-response
field, then submit. It's the same four steps in Python and in Node, and the
code is below.
First, identify which CAPTCHA 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.
If it is hCaptcha, read on. If it is reCAPTCHA, Turnstile or FunCaptcha, skip to what NoneCap does not solve.
Solve hCaptcha in Playwright (Python & Node)
Don't try to beat the challenge inside your automated browser. 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 solve it
out of band. NoneCap solves it separately, off your
machine, then returns a normal P1_ token. Your Playwright script never
touches the challenge UI. It just supplies the token the page was already waiting for.
The four steps
Every integration, regardless of language, is the same shape:
- 1. Read the
data-sitekeyand the pageurl. - 2. Solve by calling
POST /v1/solves(block with?wait=N, or submit asynchronously and poll the returned solve id). - 3. Inject the returned token into
textarea[name="h-captcha-response"]viapage.evaluate, and fire the widget callback if there is one. - 4. Submit the form the way your page does.
Python (Playwright)
Using the sync API and requests for the NoneCap call:
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}, # block up to 90s for the token
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"] # a real P1_… token
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://target.example/login")
# 1. Read the sitekey straight off the rendered widget.
sitekey = page.locator("[data-sitekey]").first.get_attribute("data-sitekey")
# 2. Mint a real token out-of-band with NoneCap.
token = solve_hcaptcha(sitekey, page.url)
# 3. Inject the hCaptcha token into h-captcha-response. hCaptcha's drop-in
# mode reuses reCAPTCHA's old g-recaptcha-response field name, so we set
# it too if present, it's still the hCaptcha token, not a reCAPTCHA solve.
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;
}
// Invisible widgets submit from data-callback, fire it if present.
const cb = document.querySelector('[data-callback]')?.getAttribute('data-callback');
if (cb && typeof window[cb] === 'function') window[cb](token);
}""",
token,
)
# 4. Submit the form however your page does it.
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.
Node (Playwright)
The same flow in JavaScript with fetch and the Playwright Node client:
import { chromium } from "playwright";
const API = "https://api.nonecap.com/v1/solves";
const HEADERS = { Authorization: `Bearer ${process.env.NONECAP_KEY}` };
async function solveWithNoneCap(sitekey, url) {
const res = await fetch(`${API}?wait=90`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ type: "hcaptcha", sitekey, url }),
});
let data = await res.json();
while (data.status === "pending" || data.status === "solving") {
await new Promise((r) => setTimeout(r, 2000));
data = await (await fetch(`${API}/${data.id}`, { headers: HEADERS })).json();
}
if (data.status !== "solved") {
throw new Error(`solve ${data.status}: ${JSON.stringify(data.error)}`);
}
return data.token; // a real P1_… token
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://target.example/login");
// 1. Read the sitekey straight off the rendered widget.
const sitekey = await page
.locator("[data-sitekey]")
.first()
.getAttribute("data-sitekey");
// 2. Mint a real token out-of-band with NoneCap.
const token = await solveWithNoneCap(sitekey, page.url());
// 3. Inject the hCaptcha token into h-captcha-response. hCaptcha's drop-in
// mode reuses reCAPTCHA's old g-recaptcha-response field name, so we set it
// too if present, it's still the hCaptcha token, not a reCAPTCHA solve.
await 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 however your page does it.
await page.click("button[type=submit]");
await page.waitForLoadState("networkidle");
await browser.close(); hCaptcha checkbox vs invisible vs enterprise
The request you send NoneCap depends on which kind of hCaptcha the page renders. The
injection step is identical; what changes is the type you send and, for
invisible widgets, whether you need to fire the callback yourself.
| Sitekey type | How you spot it | What to send NoneCap |
|---|---|---|
| Checkbox | A visible "I am human" widget renders; a textarea[name="h-captcha-response"] appears in the form | type: "hcaptcha" + sitekey + url |
| Invisible | No widget; hcaptcha.execute() runs on an action and a data-callback handles the token | type: "hcaptcha" + sitekey + url, then fire the callback |
Enterprise (rqdata) | The page passes a fresh rqdata blob into hcaptcha.render() / execute() | type: "hcaptcha_enterprise" + sitekey + url + rqdata |
Checkbox sitekeys render a visible widget and a
h-captcha-response textarea; set the value and submit.
Invisible sitekeys have no widget and submit from a
data-callback, so after injecting you call window[callbackName](token),
which the examples above already do.
Enterprise rqdata
Enterprise hCaptcha is where in-browser tricks fall down hardest: each challenge
carries an IP-bound rqdata blob, so a token has to be minted in a real
session that matches it. Capture the rqdata the page passes into
hcaptcha.render() / execute() and forward it with the
enterprise type:
# Enterprise sitekeys also need the per-challenge rqdata blob.
# Read it from the call the page makes (hcaptcha.render / execute),
# then forward it with the enterprise type:
requests.post(
API,
headers=HEADERS,
params={"wait": 90},
json={
"type": "hcaptcha_enterprise",
"sitekey": sitekey,
"url": page.url,
"rqdata": rqdata, # the fresh, IP-bound challenge blob
},
) Async instead of blocking
The examples block with ?wait=90, which is the simplest path for a
single solve. For high-throughput crawls you can omit ?wait and poll
GET /v1/solves/{id} yourself. Concurrency is capped per account
(5 on the free trial, up to 50 on paid packs and 200 on the Enterprise plan), so a fleet of Playwright workers can run
in parallel up to that cap. The full object and every language sample are in the
API reference.
Trigger fewer challenges
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.
Why stealth plugins aren't enough
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.
Modern hCaptcha scores the whole session: the TLS and browser fingerprint, the
pointer and timing behaviour, the reputation of the IP. It only hands out a token
when that score looks human. Enterprise sitekeys go further and bind every challenge to a
fresh rqdata blob tied to your request. A patched automation browser
is exactly the profile these systems are built to deny.
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.
reCAPTCHA, Turnstile, and FunCaptcha: what NoneCap does not solve
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 triggering fewer challenges 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 that same 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.
And if your target has no captcha at all, you don't need a solver. Drive the form directly in Playwright and skip the token step entirely.
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.
Driving Puppeteer or
Selenium instead of Playwright? The flow is
identical. Read the data-sitekey, mint the token, set
h-captcha-response in the DOM, and fire the callback. For the wider
pipeline, see hCaptcha for web scraping.
Last updated October 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.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.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.Where do I get the sitekey and url to send?
sitekey is on the rendered widget. Read it in Playwright with page.locator("[data-sitekey]").get_attribute("data-sitekey") (or scrape it from the hcaptcha.render() call). The url is just the page the challenge appears on, i.e. page.url. Send both to POST /v1/solves.The widget is invisible and injecting the token does nothing. What now?
data-callback function. After setting textarea[name="h-captcha-response"], look up the callback name from the data-callback attribute and call window[name](token), exactly as the example does. If the form posts through your own handler instead, just click submit after injecting.