DEV Community

Cover image for reCAPTCHA v2 solver in Python: solve the checkbox with an API and submit the token in Selenium
Bassem Shahin
Bassem Shahin

Posted on Fully Autonomous

reCAPTCHA v2 solver in Python: solve the checkbox with an API and submit the token in Selenium

Your Selenium script fills in a form, reaches the "I'm not a robot" checkbox and stops. Clicking the box from automation usually opens an image grid, and the grid keeps coming back. Even when a run gets through, it isn't something you can repeat a thousand times a day.

The checkbox itself doesn't matter, though. What the site checks is a token. When a person solves the widget, reCAPTCHA writes a token into a hidden g-recaptcha-response field, and the site's backend sends that token to Google to verify. A solving API returns the same kind of token from two inputs: the sitekey and the page URL. So a reCAPTCHA v2 solver in Python has three jobs: read the sitekey, get a token from the API, and put the token where the form reads it before you submit.

This post does all three against Google's own checkbox demo, first in Selenium and then with plain requests. I ran everything on 2026-10-10, and every output below is copied from those runs.

What the token is, and what Google checks

The token is a long opaque string (2,489 characters in the Selenium run below). The site's server posts it to Google's siteverify endpoint with its secret key. Google answers with success and the hostname the token was issued for. Two rules from Google's verification docs shape the code:

  • A token is valid for two minutes.
  • A token can be verified once.

So get the token right before you submit, use it straight away, and never reuse one.

For a standard v2 checkbox, the browser that submits the token doesn't have to be the one that solved it. In my runs, Google's demos accepted API tokens submitted from headless Chrome, where navigator.webdriver was true, and from requests, with no browser at all. Enterprise keys can be stricter; there's more on that under "What can still go wrong".

Step 1: read the sitekey from the anchor iframe

A sitekey is 40 characters long and starts with 6L. When the widget is declared in the HTML you can read it from data-sitekey="6L...". Many sites render the widget from JavaScript instead, so in Selenium it's more reliable to read what reCAPTCHA actually rendered. Every widget gets an anchor iframe, and its URL carries the sitekey as k= and the widget type as size=. These are the anchors on Google's checkbox demo and on its invisible demo:

https://www.google.com/recaptcha/api2/anchor?ar=1&k=6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-&co=aHR0cHM6Ly93d3cuZ29vZ2xlLmNvbTo0NDM.&hl=en&v=nY0xPItgwtlvjEjk2TqdwOQz&size=normal&sa=action&anchor-ms=20000&execute-ms=30000&cb=go5o9v7hms4v
https://www.google.com/recaptcha/api2/anchor?ar=1&k=6LfP0CITAAAAAHq9FOgCo7v_fb0-pmmH9VW3ziFs&co=aHR0cHM6Ly93d3cuZ29vZ2xlLmNvbTo0NDM.&hl=en&v=nY0xPItgwtlvjEjk2TqdwOQz&size=invisible&sa=action&anchor-ms=20000&execute-ms=30000&cb=f34pt91snzb9
Enter fullscreen mode Exit fullscreen mode

A checkbox reports size=normal (or compact). Invisible v2 and v3 both report size=invisible, and they need different solver parameters, so the script below skips them. If that's what you have, the invisible reCAPTCHA v2 walkthrough and the v3 solver post cover them.

Two more things the anchor URL tells you. The path is /recaptcha/api2/anchor for a standard key and /recaptcha/enterprise/anchor when the widget is served as Enterprise. And sa= is present on the standard demo too, so sa on its own doesn't mean Enterprise.

Step 2: get a token from the API

The solve is a standard reCAPTCHA v2 task on the 2Captcha-style in.php / res.php API:

Parameter Value
method userrecaptcha
googlekey the sitekey from step 1
pageurl the full URL of the page that shows the widget
json 1, so the replies are JSON

There's no invisible=1 and no version for a checkbox. You get a task id back. Wait about 15 seconds, then poll res.php every 5 seconds until the token arrives. Server errors are retried with a backoff capped at 30 seconds. Account and input errors stop at once, because retrying won't fix them.

"""recaptcha_v2.py: get a reCAPTCHA v2 (checkbox) token from a solving API. Needs: pip install requests"""
import os
import re
import time

import requests

API = "https://ocr.captchaai.com"  # 2Captcha-style in.php / res.php
API_KEY = os.environ.get("CAPTCHAAI_KEY", "YOUR_API_KEY")
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


def call(path, **kw):
    """One API call; network errors and non-JSON replies come back as a transient error."""
    try:
        return requests.request(timeout=30, url=f"{API}/{path}", **kw).json()
    except (requests.RequestException, ValueError) as exc:
        return {"status": 0, "request": "ERROR_SERVER_ERROR", "detail": str(exc)}


def solve_recaptcha_v2(sitekey, pageurl, timeout=180, log=print):
    """Return a g-recaptcha-response token for a checkbox widget."""
    if API_KEY in ("", "YOUR_API_KEY"):
        raise SystemExit("Set CAPTCHAAI_KEY to your API key first")
    if not re.fullmatch(r"6L[\w-]{38}", sitekey):
        raise SystemExit(f"{sitekey!r} doesn't look like a reCAPTCHA sitekey")
    params = {"key": API_KEY, "method": "userrecaptcha", "googlekey": sitekey,
              "pageurl": pageurl, "json": 1}
    for attempt in range(3):
        r = call("in.php", method="POST", data=params)
        if r["status"] == 1:
            break
        if r["request"] not in TRANSIENT:  # ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE,
            raise RuntimeError(f"in.php: {r['request']}")  # ERROR_PAGEURL, ...
        time.sleep(5 * 2 ** attempt)
    else:
        raise RuntimeError("in.php: server errors on 3 tries")

    task_id, started, wait = r["request"], time.time(), 5
    log(f"task {task_id} submitted")
    time.sleep(15)  # nothing is ready before ~15 s
    while time.time() - started < timeout:
        r = call("res.php", method="GET", params={
            "key": API_KEY, "action": "get", "id": task_id, "json": 1})
        if r["status"] == 1:
            log(f"solved in {time.time() - started:.0f}s")
            return r["request"]
        if r["request"] in TRANSIENT:
            wait = min(wait * 2, 30)  # back off on server trouble
        elif r["request"] == "CAPCHA_NOT_READY":
            wait = 5
        else:  # ERROR_CAPTCHA_UNSOLVABLE, ERROR_WRONG_CAPTCHA_ID, ...
            raise RuntimeError(f"res.php: {r['request']}")
        time.sleep(wait)
    raise TimeoutError(f"task {task_id} not solved in {timeout}s")
Enter fullscreen mode Exit fullscreen mode

The timeout is 180 seconds, not 60. Solve times vary, and the next sections show what they were in my runs.

Step 3: put the token where the form reads it

This is where most Selenium attempts break. The token belongs in the g-recaptcha-response textarea, which reCAPTCHA adds to the page itself, not inside the iframe. It's hidden with display: none, so send_keys can't reach it. This is what my probe of the demo page printed:

textarea id=g-recaptcha-response displayed=False style='width: 250px; height: 40px; border: 1px solid rgb(193, 193, 193); margin: 10px 25px; padding: 0px; resize: none; display: none;'
send_keys: ElementNotInteractableException Message: element not interactable
Enter fullscreen mode Exit fullscreen mode

So set the value with JavaScript. Then do what reCAPTCHA would do after a real solve: call the widget's callback if it has one. On Google's demo that's data-callback="onSuccess", a function that clears the "please verify" error. On real sites, the callback often enables the submit button or submits the form itself, and filling the textarea without calling it changes nothing.

The whole flow in Selenium

Prerequisites: Python 3.9+, Chrome, and pip install selenium requests, with recaptcha_v2.py from step 2 in the same folder. Selenium 4.6+ downloads a matching chromedriver by itself.

# selenium_recaptcha_v2.py: solve a reCAPTCHA v2 checkbox in Selenium with a solving API.
# Needs: pip install selenium requests   (recaptcha_v2.py sits next to it, Chrome installed)
import sys
from urllib.parse import parse_qs, urlparse

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

from recaptcha_v2 import solve_recaptcha_v2

ANCHOR = 'iframe[src*="/recaptcha/"][src*="/anchor"]'  # one per rendered widget

# Fill every response textarea (it's hidden, so send_keys can't reach it),
# then call the widget's data-callback if it has one, as reCAPTCHA would.
INJECT = """
const [token, sitekey] = arguments;
document.querySelectorAll('textarea[name="g-recaptcha-response"]')
  .forEach((t) => { t.value = token; });
const el = document.querySelector(`[data-sitekey="${sitekey}"][data-callback]`);
const name = el ? el.getAttribute("data-callback") : null;
let fn = window;
for (const part of (name || "").split(".").filter(Boolean)) fn = fn ? fn[part] : undefined;
if (typeof fn === "function") { fn(token); return name; }
return null;
"""


def find_checkbox(driver, timeout=20):
    """Read the sitekey from the widget's anchor iframe, the one reCAPTCHA really rendered."""
    WebDriverWait(driver, timeout).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, ANCHOR)))
    for frame in driver.find_elements(By.CSS_SELECTOR, ANCHOR):
        q = parse_qs(urlparse(frame.get_attribute("src")).query)
        if q.get("size", [""])[0] != "invisible":  # invisible v2 and v3 say "invisible"
            return q["k"][0]
    raise SystemExit("No checkbox widget: the page uses invisible v2 or v3")


def main(url, token=None):
    opts = webdriver.ChromeOptions()
    opts.add_argument("--headless=new")
    driver = webdriver.Chrome(options=opts)
    try:
        driver.get(url)
        sitekey = find_checkbox(driver)
        print("sitekey:", sitekey)

        # pageurl is the page that shows the widget, after any redirects
        token = token or solve_recaptcha_v2(sitekey, driver.current_url)
        print("token:", token[:20] + "...", f"({len(token)} chars)")
        print("callback called:", driver.execute_script(INJECT, token, sitekey))

        old_page = driver.find_element(By.TAG_NAME, "html")
        driver.find_element(By.CSS_SELECTOR, "form [type=submit]").click()
        WebDriverWait(driver, 20).until(EC.staleness_of(old_page))  # the form posted
        # Google's demo answers with one of these two lines; on your site, use its own signal
        lines = driver.find_element(By.TAG_NAME, "body").text.splitlines()
        print("demo says:", [s for s in lines if "Hooray" in s or "not a robot" in s])
    finally:
        driver.quit()


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "https://www.google.com/recaptcha/api2/demo",
         sys.argv[2] if len(sys.argv) > 2 else None)
Enter fullscreen mode Exit fullscreen mode

With an API key set and no second argument, it solves and submits:

$ CAPTCHAAI_KEY=... python selenium_recaptcha_v2.py
sitekey: 6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-
task 5520654748 submitted
solved in 91s
token: 0cAFcWeA4OJwZvvxnoKD... (2489 chars)
callback called: onSuccess
demo says: ['Verification Success... Hooray!']
Enter fullscreen mode Exit fullscreen mode

Verification Success... Hooray! is the demo's answer after its backend verified the token with Google.

Test the plumbing without spending a solve

The optional second argument is used as the token, so you can check the page side for free. A fake token shows what a rejection looks like. The script finds the widget, fills the field, calls the callback and submits, and the demo refuses the token:

$ python selenium_recaptcha_v2.py https://www.google.com/recaptcha/api2/demo 03AFcWeA-not-a-real-token
sitekey: 6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-
token: 03AFcWeA-not-a-real-... (25 chars)
callback called: onSuccess
demo says: ['Please verify that you are not a robot.']
Enter fullscreen mode Exit fullscreen mode

Pointed at the invisible demo, the script stops before it spends anything:

$ python selenium_recaptcha_v2.py "https://www.google.com/recaptcha/api2/demo?invisible=true"
No checkbox widget: the page uses invisible v2 or v3
Enter fullscreen mode Exit fullscreen mode

No browser: the same token with requests

If the form is plain HTML and posts normally, you don't need Selenium at all. Google's other checkbox demo, on recaptcha-demo.appspot.com, is that kind of form, and its backend prints its own siteverify result. The regexes below fit that demo's markup. For real forms, use a proper parser like the finder in the invisible post.

# requests_recaptcha_v2.py: the same solve with no browser, for a plain HTML form.
# Needs: pip install requests   (recaptcha_v2.py sits next to it)
import re
import sys

import requests

from recaptcha_v2 import solve_recaptcha_v2

page = sys.argv[1] if len(sys.argv) > 1 else \
    "https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php"
s = requests.Session()
html = s.get(page, timeout=30).text
sitekey = re.search(r'data-sitekey="(6L[\w-]{38})"', html).group(1)
action = re.search(r'<form[^>]*action="([^"]*)"', html).group(1)
fields = dict(re.findall(r'<input type="text" name="([^"]+)" value="([^"]*)"', html))
print("sitekey:", sitekey, "| form:", action, fields)

token = sys.argv[2] if len(sys.argv) > 2 else solve_recaptcha_v2(sitekey, page)
reply = s.post(requests.compat.urljoin(page, action),
               data={**fields, "g-recaptcha-response": token}, timeout=30)
# the demo backend prints its own siteverify result
print("siteverify:", re.findall(r"'(success|hostname)' => '?([\w.-]+)", reply.text),
      re.findall(r"\d+ => '([a-z-]+)'", reply.text))
Enter fullscreen mode Exit fullscreen mode
$ CAPTCHAAI_KEY=... python requests_recaptcha_v2.py
sitekey: 6LfW6wATAAAAAHLqO2pb8bDBahxlMxNdo9g947u9 | form: /recaptcha-v2-checkbox.php {'ex-a': 'foo', 'ex-b': 'bar'}
task 5520667118 submitted
solved in 90s
siteverify: [('success', 'true'), ('hostname', 'recaptcha-demo.appspot.com')] []
Enter fullscreen mode Exit fullscreen mode

With the fake token, this demo spells out why Google rejected it:

$ python requests_recaptcha_v2.py https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php 03AFcWeA-not-a-real-token
sitekey: 6LfW6wATAAAAAHLqO2pb8bDBahxlMxNdo9g947u9 | form: /recaptcha-v2-checkbox.php {'ex-a': 'foo', 'ex-b': 'bar'}
siteverify: [('success', 'false'), ('hostname', 'NULL')] ['invalid-input-response', 'hostname-mismatch']
Enter fullscreen mode Exit fullscreen mode

What can still go wrong

  • ElementNotInteractableException on the textarea. You used send_keys. The field is hidden, so set .value with execute_script as above.
  • The form still says "please verify" with a real token. Most often the token expired or was already used: it's valid for two minutes from when the solver got it, so don't solve ahead and queue tokens. Next, check that the token went into the textarea the form posts. A page with two widgets has g-recaptcha-response and g-recaptcha-response-1. The script fills all of them, which is fine for one widget but not for two different forms.
  • Nothing happens after you inject. The site waits for its callback, and this one isn't in data-callback. Sites that call grecaptcha.render(el, {callback: fn}) keep it in reCAPTCHA's internal config instead. This post on reCAPTCHA v2 callbacks shows how to find and trigger it from Selenium.
  • find_checkbox times out. The widget is inside another iframe, so the anchor isn't in the top document. Use driver.switch_to.frame(...) into that frame first and fill the textarea there. Send the frame's own URL as pageurl, because that's the page that shows the widget. driver.current_url still returns the top page, so read it with driver.execute_script("return location.href") inside the frame.
  • The submit wait times out after a successful inject. The form submits by XHR and the page never reloads. Wait for the site's own success element instead of staleness_of.
  • TimeoutError: task ... not solved in 180s. It happened to me once in five solves while writing this. The other four took 45, 114, 91 and 90 seconds. Submit a new task, and don't lower the timeout to make a slow solve fail sooner.
  • ERROR_WRONG_SITEKEY or ERROR_PAGEURL from in.php. The sitekey is blank or malformed, or pageurl is missing. Read both from the page again.
  • ERROR_WRONG_KEY_TYPE. The key isn't a v2 checkbox key. Check size= in the anchor URL.
  • An Enterprise checkbox. If the anchor path has /enterprise/ and the page calls grecaptcha.enterprise, send enterprise=1, and the sa= value as action if the anchor has one. The reply then includes the solver's User-Agent, which you have to submit with, so check the response format in the reCAPTCHA v2 Enterprise guide before you reuse solve_recaptcha_v2(). The anchor path is only a hint, though: this comparison of v2, v3 and Enterprise shows a demo whose standard key was served from the Enterprise path in most runs.
  • The token is accepted but the site still blocks you. reCAPTCHA is often only one of the site's checks. A valid token doesn't change navigator.webdriver or your IP's reputation. The demo didn't care about either, but your target might.
  • Old snippets fail with AttributeError: 'WebDriver' object has no attribute 'find_element_by_id'. Selenium 4.3 removed the find_element_by_* helpers. Use find_element(By.ID, ...).

FAQ

Can Selenium just click the checkbox?
You can switch into the anchor iframe and click #recaptcha-anchor, but an automated browser often gets the image challenge, and then you're solving grids. The token route skips the widget entirely.

Do I need a proxy for reCAPTCHA v2?
Not for the runs above, which used none. The API accepts an optional proxy and User-Agent for sites that check more than the token. Enterprise keys are where that comes up most.

How long can I keep a token?
Two minutes from issue, and one verification. Solve right before you submit.


Disclosure: I run CaptchaAI, the API these examples call. This article was written by an AI agent, and every output above is from a real run on 2026-10-10.

— Bassem

Top comments (0)