CAPTCHA Solving 20 min read

How to Solve reCAPTCHA with Selenium in Python

Solve reCAPTCHA in Python with Selenium: read the site key, buy a token, inject it before the two-minute clock runs out. Code tested against Selenium 4.47.0, solver prices read from vendor pages on 13 August 2026.

ST
Scraping.Pro Team
Data collection for business needs
Published: 6 October 2025

A Selenium script that runs clean on your laptop meets its first reCAPTCHA somewhere around page forty of a real crawl. From there the arithmetic changes. Buying a token for every page of a ten-thousand-page run costs between $6 and $30 at the prices the four main solver APIs published on 13 August 2026. It also adds three to ten seconds per page, which is eight to twenty-eight hours of wall clock if you run pages one at a time. The interesting engineering question is not how to solve a reCAPTCHA. It is how seldom you can get away with solving one.

The mechanics still matter, because sooner or later you need them: read the site key, buy a token, put that token where the page actually reads it. Step three is where most write-ups go wrong, and the earlier version of this article was one of them. We ran the injection code in a real browser this month, and the measurements below say exactly how it fails.

Everything here was checked against Python 3.11, Selenium 4.47.0 (released 10 August 2026) and 2captcha-python 2.1.0. Prices come from each vendor's own pricing page, read on 13 August 2026. Sister articles cover the same job from other angles: reCAPTCHA v2 and 2Captcha in plain Python, the C# integration, adding reCAPTCHA to a PHP form if you are on the defending side, and a price and coverage comparison of the solver market.

A quick, honest disclaimer: solving CAPTCHAs to automate a site can violate that site's terms of service. Do it on properties you own or are authorized to test, respect robots.txt and rate limits, and never use these techniques for fraud or account abuse.


What a solve costs before you write a line of code

Prices per 1,000 solves, read from each vendor's own page on 13 August 2026:

Service reCAPTCHA v2 reCAPTCHA v3 Enterprise Speed the vendor advertises
2Captcha $1–$2.99 $1.45 at score ≤ 0.3, $2.99 above $1–$2.99 not readable
Anti-Captcha $0.95–$2 $1–$2 $5 5 s average
CapSolver $0.80 $1.00 $1 for v2, $3 for v3 under 5 s, under 3 s for v3
CapMonster Cloud $0.60 $0.90 $1 for v2, $1.50 for v3 not stated

Two things about that table. 2Captcha's pricing page renders its speed column as a row of zeroes to anything that is not a full browser, so we have no latency figure from them worth quoting. And the ranges are not tiers you choose. They are bid ranges: the low end is what a job costs when the queue is empty and workers are idle, the high end is what it costs when it is not.

Do the arithmetic before you build. Ten thousand pages, one solve each, is $6 at CapMonster's advertised rate and $29.90 at 2Captcha's ceiling. That is small money. The time is not. At Anti-Captcha's advertised five-second average, ten thousand serial solves is thirteen hours and fifty-three minutes of doing nothing but waiting. Run twenty solves concurrently and it drops to forty-two minutes, which is the number that decides whether the crawl fits in a night. One extra second per page across the same run is two hours and forty-seven minutes.

None of that includes the pages where the solve fails and you pay twice, and none of it includes proxy cost, which on a job like this usually exceeds the solver bill.


Which reCAPTCHA is in front of you

Identify the variant first, because it decides what you send and what you get back.

  • reCAPTCHA v2, the checkbox. A div.g-recaptcha with a data-sitekey attribute. Ticking the box may pass you outright or may escalate to an image grid.
  • reCAPTCHA v2 invisible. Same engine, no checkbox, fires on a button click or on grecaptcha.execute(). Google's own invisible reCAPTCHA documentation states that "the g-recaptcha-response token is passed to your callback", which matters later: on an invisible widget the callback is the delivery path, not a nicety.
  • reCAPTCHA v3. No challenge, no checkbox, no grid. It returns a score and the site decides. Google's v3 documentation is blunt about the scale: "1.0 is very likely a good interaction, 0.0 is very likely a bot", and "by default, you can use a threshold of 0.5".
  • reCAPTCHA Enterprise. The page calls grecaptcha.enterprise.execute(...). One line of JavaScript tells you which you are on: !!(window.grecaptcha && window.grecaptcha.enterprise).

A correction to the earlier version of this article, which called Enterprise "a paid, hardened version". Google's reCAPTCHA FAQ says Enterprise "offers up to 10,000 assessments per month at no cost", and that classic reCAPTCHA is the version with a hard ceiling: "if you want to make more than 1,000 calls per second or 1,000,000 calls per month, you must use reCAPTCHA Enterprise or fill out this form and wait for an exception approval". Enterprise is not the paid tier. It is where Google steers any site with traffic, and the migration "requires no code changes" by Google's own account. Expect to meet it more often each year, not less.

What the widget measures, and how much of that is knowable

Mouse paths, event timing, scroll behavior, keystroke cadence, cookie age and IP reputation all feed the score. Every list of signals in circulation, including the one the earlier version of this article printed as fact, is reverse-engineering plus solver marketing. Google publishes the score and the action name, and nothing underneath them. The honest version is that the raw signals go to Google encrypted, the site never sees them, and the only thing that crosses the boundary is a token.

Why random tile clicking never came back

The 2015-era trick was to click grid tiles at random and resubmit. Numbers get quoted for the odds of a lucky solve, usually two or three percent, and none of them trace to a published measurement. What is documented is simpler and more final: a response token is valid for two minutes, and grids only appear on v2 at all. Invisible v2 and v3 give you nothing to click. Whatever the odds once were, there is no longer a puzzle on most of the pages that stop you.

Solvers are for the residue.


The token is the entire protocol

Everything else is plumbing around one string. Google's verification documentation states the constraint in one sentence: "Each reCAPTCHA user response token is valid for two minutes, and can only be verified once to prevent replay attacks."

The earlier version of this article said the token was "valid for about two minutes" and stopped there. The missing half is what actually bites. Single use means a retry loop that resubmits the same token gets the same rejection forever, and the error code is timeout-or-duplicate, documented as "the response is no longer valid: either is too old or has been used previously". One error string covers both an expired token and a replayed one, so from the outside you cannot tell which mistake you made.

Two more properties decide whether a token works:

  • The clock starts when the worker solved it, not when your code received it. A solver that took ninety seconds hands you a token with thirty seconds of life left. The 2captcha-python client polls every ten seconds by default, so up to another ten seconds of that budget is gone before your process sees the string.
  • siteverify returns the hostname where the captcha was solved. Solve against a URL that differs from the one you submit from, even by a redirect, and a site that checks the field rejects a token that looks perfectly valid.

Selenium cannot produce this string. Driving a browser gets you the site key and gets the token onto the page; it does not get you past Google's risk model, because the model is not evaluated in your process. That is the whole reason an external solver exists.


Step 1: read the site key

The site key is public by design. It sits in data-sitekey, and when there is no visible widget it is the k= parameter of the reCAPTCHA iframe URL. This helper covers both and reports whether the page is on Enterprise:

python
from urllib.parse import urlparse, parse_qs

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException


def read_sitekey(driver, timeout=15):
    """Return (sitekey, is_enterprise) for the first reCAPTCHA on the page."""
    try:
        widget = WebDriverWait(driver, timeout).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, "[data-sitekey]"))
        )
        key = widget.get_attribute("data-sitekey")
    except TimeoutException:
        key = None

    if not key:
        frames = driver.find_elements(By.CSS_SELECTOR, "iframe[src*='recaptcha']")
        if not frames:
            raise LookupError("no reCAPTCHA widget on this page")
        query = parse_qs(urlparse(frames[0].get_attribute("src")).query)
        key = query["k"][0]

    enterprise = driver.execute_script(
        "return !!(window.grecaptcha && window.grecaptcha.enterprise);"
    )
    return key, bool(enterprise)

The selector is [data-sitekey] rather than .g-recaptcha on purpose. Plenty of integrations put the attribute on the submit button instead of a div, and the class name is not guaranteed. find_elements returns an empty list instead of raising, which is why the iframe branch needs no second try.

You do not need to enter the reCAPTCHA iframes for any of this, and driver.switch_to_frame() is not available to you anyway. We checked on Selenium 4.47.0: WebDriver exposes exactly one member matching switch, and it is the switch_to object. Both switch_to_frame and switch_to_default_content are gone, replaced by driver.switch_to.frame(...).


Step 2: buy a token

bash
pip install selenium 2captcha-python

Selenium ships Selenium Manager with every release "as of version 4.6", so nobody downloads chromedriver by hand any more, and "as of Selenium 4.11.0, Selenium Manager also implements automated browser management" and will fetch the browser too. It is still labeled beta after four years, and it needs outbound network access to Google's Chrome for Testing endpoints. In a locked-down container it fails, and the fix is to pin a driver binary yourself.

The solving call itself is four lines:

python
from twocaptcha import TwoCaptcha

solver = TwoCaptcha(API_KEY, polling_interval=5)
answer = solver.recaptcha(sitekey=sitekey, url=driver.current_url, enterprise=1)
token = answer["code"]

Details worth knowing before this runs unattended, all of them read out of the library's own source rather than the marketing pages:

  • The defaults are generous. default_timeout is 120 seconds, recaptcha_timeout is 600, polling_interval is 10. A stuck reCAPTCHA job blocks your worker for ten minutes before it raises. Dropping the polling interval to 5 halves the average delay between the worker finishing and your process learning about it, and buys back five seconds of the token's two-minute life.
  • NetworkException means two different things. Internally the client raises it both when the API answers CAPCHA_NOT_READY and when the HTTP request genuinely fails, and wait_result swallows both and sleeps. A solver outage, a DNS failure and a slow captcha are indistinguishable until recaptcha_timeout expires.
  • Version 2.1.0 still talks to the old API. ApiClient posts to https://2captcha.com/in.php and res.php, not the newer createTask JSON endpoints. Your key travels as a form field, which is fine, but do not assume the library follows the v2 docs you are reading.
  • data-s cannot be passed as a keyword. It is a legal parameter name for the HTTP API and an illegal Python identifier. Use solver.recaptcha(sitekey=..., url=..., **{"data-s": value}).
  • Setting callback makes solve() return None. Configure a pingback URL and the client submits the job and returns without a result, by design. Code that reads answer["code"] then dies on a TypeError a long way from the cause.
  • The exceptions live one level down. from twocaptcha.exceptions.solver import ApiException, NetworkException, TimeoutException. ApiException is what carries ERROR_ZERO_BALANCE, ERROR_KEY_DOES_NOT_EXIST and ERROR_CAPTCHA_UNSOLVABLE, because ApiClient raises it for any response containing the string ERROR.

Every competing API mirrors this shape closely enough that swapping providers is a base URL and a parameter rename. That is the practical argument for wiring a second provider in early rather than after the first outage.


Step 3: put the token where the page reads it

The token belongs in the hidden textarea named g-recaptcha-response, and then the widget's callback has to fire. Almost every tutorial, this one included until now, writes it like this:

python
driver.execute_script(
    "document.getElementById('g-recaptcha-response').innerHTML = arguments[0];", token
)

That line works often enough to look correct and fails silently when it does not.

What we measured

Setup. Chromium 141.0.7390.37 driven by chromedriver 141.0.7390.54 through Selenium 4.47.0 on Python 3.11, --headless=new, against a local HTML file reproducing the DOM shape reCAPTCHA leaves behind: two forms, two hidden textareas both named g-recaptcha-response with ids g-recaptcha-response and g-recaptcha-response-1, one plain data-callback and one written as a dotted path.

  • On a field nothing has written to yet, innerHTML works. Set it to TOKEN_A, read .value back, get TOKEN_A.
  • Once anything has assigned to .value, innerHTML stops working, without an error. We wrote OLD_EXPIRED to .value, then set .innerHTML to TOKEN_B. Reading .innerHTML gave TOKEN_B. Reading .value gave OLD_EXPIRED. The form submits the old string. This is the HTML dirty-value flag: a textarea's value tracks its child text only while nothing has written to the value directly. reCAPTCHA writes to the value itself, so any page where the widget already produced a token, a checkbox that was ticked and expired, or a second attempt after a rejected token, is a page where the innerHTML trick is dead.
  • .value works in both states. There is no case where innerHTML succeeds and .value does not.
  • getElementById finds one field out of two. document.getElementsByName('g-recaptcha-response') returned 2. document.getElementById('g-recaptcha-response') returned the first form's field. On a login page with a second widget in a newsletter box, the token lands in the wrong form and the submit fails for no visible reason. Google's own API acknowledges the plural case: grecaptcha.getResponse(opt_widget_id) takes a widget id, "defaults to the first widget created if unspecified", per the display documentation.
  • window[cb] is undefined for a dotted callback. With data-callback="app.onSolveTwo", typeof window["app.onSolveTwo"] is undefined, so the popular one-liner does nothing and reports nothing. Splitting on dots and walking window resolves it, and the callback then fires with the token.
  • An execute_script round trip cost 2.37 ms on average over 200 calls. Injection is not where your time goes.

Measured on one machine against a local file. The 2.37 ms is a floor, not a promise. The five behavioral results are browser semantics rather than timing and will reproduce anywhere.

Injection that survives all of that

python
INJECT = """
const token = arguments[0], index = arguments[1];
const fields = document.getElementsByName('g-recaptcha-response');
if (!fields.length) { return 'no-response-field'; }
const field = fields[index] || fields[0];
field.value = token;
const widgets = document.querySelectorAll('.g-recaptcha, [data-sitekey]');
const widget = widgets[index] || widgets[0];
const name = widget && widget.getAttribute('data-callback');
if (!name) { return 'field set, no callback declared'; }
const fn = name.split('.').reduce((o, k) => (o ? o[k] : undefined), window);
if (typeof fn !== 'function') { return 'callback declared but missing: ' + name; }
fn(token);
return 'callback fired: ' + name;
"""

status = driver.execute_script(INJECT, token, 0)

It returns a string instead of failing quietly, which is the point. Log that string. When a run starts failing, no-response-field and callback declared but missing tell you the page changed, and they tell you in one line instead of a night of guessing.


The whole script

python
"""reCAPTCHA v2 through 2Captcha, driven by Selenium.

Checked against Python 3.11, selenium 4.47.0, 2captcha-python 2.1.0.
"""
import os
import time
from urllib.parse import urlparse, parse_qs

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 selenium.common.exceptions import TimeoutException
from twocaptcha import TwoCaptcha
from twocaptcha.exceptions.solver import ApiException, NetworkException
from twocaptcha.exceptions.solver import TimeoutException as SolverTimeout

TARGET_URL = "https://example.com/form-with-recaptcha"
TOKEN_BUDGET = 90          # seconds; the token dies at 120

# read_sitekey() from Step 1 and the INJECT string from Step 3 belong here.


def solve_and_submit(driver, solver):
    driver.get(TARGET_URL)
    sitekey, enterprise = read_sitekey(driver)          # from Step 1
    page_url = driver.current_url                       # after redirects

    started = time.monotonic()
    try:
        answer = solver.recaptcha(
            sitekey=sitekey,
            url=page_url,
            enterprise=1 if enterprise else 0,
        )
    except ApiException as exc:                         # ERROR_ZERO_BALANCE etc.
        raise RuntimeError(f"2captcha refused the job: {exc}") from exc
    except (SolverTimeout, NetworkException) as exc:
        raise RuntimeError(f"no token within the timeout: {exc}") from exc

    age = time.monotonic() - started
    if age > TOKEN_BUDGET:
        raise RuntimeError(f"token arrived {age:.0f}s old, not worth submitting")

    status = driver.execute_script(INJECT, answer["code"], 0)   # from Step 3
    print(f"solved in {age:.1f}s, injection: {status}")

    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
    try:
        WebDriverWait(driver, 20).until(EC.url_changes(page_url))
    except TimeoutException:
        # An AJAX form never changes the URL. Wait for the real success marker.
        WebDriverWait(driver, 20).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, ".success, .thank-you"))
        )
    return answer


def main():
    solver = TwoCaptcha(os.environ["TWOCAPTCHA_API_KEY"], polling_interval=5)
    driver = webdriver.Chrome()
    try:
        answer = solve_and_submit(driver, solver)
        if "reject" in driver.page_source.lower():
            solver.report(answer["captchaId"], False)   # refunds a bad token
    finally:
        driver.quit()


if __name__ == "__main__":
    main()

Four differences from the version this article used to print, and each one comes from a real failure. The key comes from the environment rather than a literal. page_url is captured after redirects, because solving against the pre-redirect URL is one of the most common causes of a rejected token. The token gets a budget, so a slow solve fails loudly instead of submitting something already dead. And EC.url_changes has a fallback, because it only reports success for forms that navigate; an AJAX submit leaves the URL untouched and the old script timed out on every one of them.

solver.report(captcha_id, False) is worth the two lines. A rejected token reported back is refunded, and the report feeds your account's future accuracy.


v3, and the score you cannot buy

For v3 there is usually no textarea to fill. The page calls grecaptcha.execute(sitekey, {action}), gets a token in a promise, and ships it with its own request. You pass the extra parameters to the solver and then feed the token into whatever the page does with it:

python
answer = solver.recaptcha(
    sitekey=sitekey,
    url=page_url,
    version="v3",
    action="submit",     # the action name the page uses
    score=0.4,
)

The old version of this article asked for score=0.7 and moved on. That number is not achievable, and the vendor says so in its own library. The docstring for the score parameter in 2captcha-python reads, verbatim: "The score needed for resolution. Currently, it's almost impossible to get token with score higher than 0.3. Default: 0.4."

The pricing page corroborates it. 2Captcha charges $1.45 per 1,000 for v3 at score 0.3 or below and $2.99 above it, which is what a price list looks like when the expensive case is rare. CapSolver approaches the same fact from the other side: its v3 listing tells customers who cannot reach a score of 0.7 or 0.9 to raise it with support, which is not how a parameter you can set behaves. Google recommends a default threshold of 0.5. Put those three numbers side by side and the picture is plain: a purchased v3 token typically lands below the threshold Google suggests, and no parameter changes that.

That is the real reason v3 is different in kind. On v2 you are buying an answer to a puzzle. On v3 you are buying a reputation you do not have, and reputation does not come in units of $2.99.


Where this breaks

  • The token expired in your own code. Two minutes from the worker's solve, minus polling lag, minus everything you do before clicking submit. Anything that navigates, waits for a lazy image or retries a flaky selector between token and submit is spending that budget.
  • You solved a different URL than you submitted. Redirects, a trailing slash, a locale prefix, http versus https. siteverify reports the hostname it saw and the site can compare it.
  • A second widget on the page. Measured above. The fix is to index the fields rather than reach for the id.
  • The callback is a dotted path or does not exist. Also measured above. On invisible reCAPTCHA the callback is the only delivery path, so getting this wrong means the field is full and nothing happens.
  • innerHTML on a dirtied field. The single most expensive line in the old article.
  • The retry loop replays a dead token. timeout-or-duplicate on the second attempt means solve again, not submit again.
  • The solver returned nothing and you never noticed. NetworkException conflates outages with queueing, and the default ten-minute reCAPTCHA timeout hides both.
  • The site moved to Enterprise. Same site key shape, different scoring, higher cost, and grecaptcha.enterprise where grecaptcha used to be.

Test each of these once by hand before trusting a schedule. Most of them fail without an exception, which is what makes them expensive.


What changes at ten thousand pages

A script that solves one captcha and a job that solves ten thousand are different programs.

Concurrency is a queue, not a knob. Every provider caps how many jobs one account can have open, and pushing past it returns a slot error rather than queueing politely. Retrying that error immediately turns one slow minute into a retry storm. Back off, cap in-flight solves, and treat the cap as a property of the account you are on.

Budget by success rate, not by price. A run that pays $0.60 per 1,000 with an eighteen percent rejection rate is more expensive than one that pays $2.99 with two percent, because the rejected pages cost you a second solve, a second page load and a slot. Log solve time, injection status and final outcome per page. Those three columns tell you when the target changed its widget, usually a day before your success rate falls off a cliff.

Most pages should never reach the solver. If ten thousand pages trigger ten thousand challenges, the problem is not the captcha. It is that the crawler looks like a crawler on page one, and the next section is worth more than this one.


Making the challenge not appear

A challenge you never see costs nothing and takes no time. This is where the effort belongs, and it is also where the folklore is thickest.

navigator.webdriver is a standard, not a leak. The W3C WebDriver specification defines it deliberately: the webdriver-active flag "is set to true when the user agent is under remote control", and the property exists to give sites "a standard way for co-operating user agents to inform the document that it is controlled by WebDriver". Selenium is telling the truth about itself on purpose.

The popular flag works, and fixes the smaller problem. We probed a headless Chromium 141 twice through Selenium 4.47.0. Plain, navigator.webdriver was true. With --disable-blink-features=AutomationControlled, it was false. In both runs the user agent string still read HeadlessChrome/141.0.0.0. Flipping one boolean while announcing headless mode in every request header is not a disguise. Set the user agent too, or run headful under a virtual display.

Old headless is gone, so the old advice is too. Running Chrome 141 with --headless=old here returns, verbatim: "Old Headless mode has been removed from the Chrome binary." Chrome's own headless documentation dates it: "Since Chrome 132.0.6793.0 the old Headless mode is only available as a standalone binary named chrome-headless-shell." Tutorials that tell you to prefer old headless for stealth are describing a binary you no longer have, and chrome-headless-shell is the more detectable of the two.

undetected-chromedriver has not moved since February 2024. This is the tool every list still recommends first. Its latest release on PyPI is 3.5.5, uploaded 17 February 2024, two and a half years ago, against a Chrome that has gained roughly thirty major versions since. The repository is not archived and carries around 1,100 open issues. Its own README sets the expectation better than any review does: "THIS PACKAGE DOES NOT, and i repeat DOES NOT hide your IP address, so when running from a datacenter (even smaller ones), chances are large you will not pass." It still works on some targets. It is not maintained, and a package that patches a browser it has never seen is a poor bet.

The maintained successor is SeleniumBase. UC Mode is "based on undetected-chromedriver" with "multiple updates, fixes, and improvements", and the project points past it: "for the successor to plain UC Mode, see CDP Mode". SeleniumBase shipped 4.51.12 on 10 August 2026, the same week as Selenium 4.47.0. That release cadence is the whole argument.

IP reputation outranks all of it. Datacenter ranges draw challenges on the first request no matter how clean the browser is. Rotating residential proxies are the single change that moves the score most, and they usually cost more than the solver. A headless browser on a good address beats a heavily patched one on a bad address every time.

Warm sessions score better than cold ones. Reuse cookies, keep a profile, let a session accumulate history rather than arriving fresh for every page. Google's v3 documentation says the model "learns by seeing real traffic on your site", and a client with no history is exactly what it has learned to distrust. For the wider picture on what the defending side is watching, see the breakdown of anti-scraping protection.


When not to solve at all

A solver is a recurring cost, a dependency and a moving target. Rule out the alternatives first.

If the site publishes an API or a data feed, use it; it will be faster than a browser and it will not break next quarter. If you own the target, whitelist your test addresses or lower the score threshold for known traffic instead of paying to defeat your own defense. For flows behind v3, a well-behaved session on a residential address sometimes clears the bar without a single solving call, which is the cheapest outcome available.

The audio-challenge bypass that older guides still list has the same problem in a different shape. Downloading the audio file and running it through speech-to-text does still exist in a few libraries, and it is the most heavily rate-limited path Google offers, so it degrades the moment you use it at any volume. What starts as a way to avoid paying a solver service usually ends at one anyway, several days later.

And if the point of the exercise is the data rather than the pipeline, the token flow is a strange thing to own. Sites that fight back are where managed extraction earns its keep. Where the output matters more than the machinery, data as a service moves the maintenance to whoever wants it.

Four numbers decide whether any of this works: how long the token has left, whether it landed in the right field, whether the callback fired, and how many pages needed a solve at all. Instrument those four and the rest is maintenance. Ignore them and you get a script that works on your laptop and fails at three in the morning with no stack trace.