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.

What each CAPTCHA leaves in the page, and what NoneCap covers
CAPTCHAWhat Playwright can see in the DOMSolved by NoneCap
hCaptcha.h-captcha[data-sitekey], a textarea[name="h-captcha-response"] in the form, and an iframe served from hcaptcha.comYes: 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/anchorNo, roadmap only
reCAPTCHA v3No widget at all. window.grecaptcha.execute is a function and a floating badge is usually pinned to a page cornerNo, roadmap only
Cloudflare Turnstile.cf-turnstile, an input[name="cf-turnstile-response"], and an iframe from challenges.cloudflare.comNo, roadmap only
FunCaptcha (Arkose)A container such as #arkose or #funcaptcha, an iframe from arkoselabs.com, and an fc-token valueNo, 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.

Python · detect_captcha(page)
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 reload storage_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-sitekey off the widget, and take the page URL from page.url.
  • 2. Solve with POST https://api.nonecap.com/v1/solves, sending { type: "hcaptcha", sitekey, url }.
  • 3. Inject the returned token into textarea[name="h-captcha-response"], and call the data-callback function if the widget has one.
  • 4. Submit the form however your page submits it.
Python · Playwright + NoneCap
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?
No. Playwright drives a browser; it has no way to answer a challenge or mint the token the form needs. Clicking the checkbox with 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?
It hides automation markers such as 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?
Count matching selectors: .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.
Is solving a CAPTCHA legal, or against the site’s terms?
It depends on the site’s terms and on your jurisdiction, and neither of those is something a solver can answer for you. Read the target’s terms of service, and get advice if the answer matters commercially. Plenty of legitimate work meets a challenge (your own QA suite, an account you own, a site that has asked you to integrate) and plenty of automation is prohibited by the site it points at. NoneCap’s own terms are at /terms/.
How much does solving hCaptcha cost?
Credits run $0.20 to $0.50 per 1,000 depending on the pack you buy, and there is no subscription. Billing starts at one credit per hCaptcha challenge round; hCaptcha decides how many rounds a solve takes, which is often one and sometimes two or three. Failed solves are never charged. New accounts get 1,300 free credits, which is enough to run a test suite for a while before you pay anything. Full numbers on the pricing page.

Start solving hCaptcha in minutes.

1,300 free credits on signup. Pay per solve, credits never expire, failed solves never charged.