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
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")
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
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)
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!']
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.']
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
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))
$ 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')] []
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']
What can still go wrong
-
ElementNotInteractableExceptionon the textarea. You usedsend_keys. The field is hidden, so set.valuewithexecute_scriptas 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-responseandg-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 callgrecaptcha.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_checkboxtimes out. The widget is inside another iframe, so the anchor isn't in the top document. Usedriver.switch_to.frame(...)into that frame first and fill the textarea there. Send the frame's own URL aspageurl, because that's the page that shows the widget.driver.current_urlstill returns the top page, so read it withdriver.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_SITEKEYorERROR_PAGEURLfromin.php. The sitekey is blank or malformed, orpageurlis missing. Read both from the page again. -
ERROR_WRONG_KEY_TYPE. The key isn't a v2 checkbox key. Checksize=in the anchor URL. -
An Enterprise checkbox. If the anchor path has
/enterprise/and the page callsgrecaptcha.enterprise, sendenterprise=1, and thesa=value asactionif 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 reusesolve_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.webdriveror 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 thefind_element_by_*helpers. Usefind_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)