Invisible reCAPTCHA v2 is the version with no "I'm not a robot" box. The page shows only the small "protected by reCAPTCHA" badge, and the check runs when you click submit (or when the site calls grecaptcha.execute()). If it passes, reCAPTCHA puts a token in the form's g-recaptcha-response field and calls the site's callback. To automate a form like that you need three things: the sitekey (the 40-character key that starts with 6L), a solver request with invisible=1 next to the usual method=userrecaptcha, and the right place to put the token. On a plain HTML form that place is a POST field, so you often don't need a browser at all.
This post walks through all three with code you can run against Google's public invisible demo: a Python finder that reads the sitekey and the form from the HTML, a Playwright check for widgets that only exist after JavaScript runs, and a solve-and-submit script in Python and in Node.
How to tell an invisible widget from a checkbox or v3
An invisible v2 widget is declared in one of four ways. The first three are in the HTML; the last is in a script.
| Markup | Example |
|---|---|
a div with data-size="invisible", run by grecaptcha.execute() | <div class="g-recaptcha" data-sitekey="6Lc..." data-size="invisible" data-callback="onSubmit"> |
the g-recaptcha class on the submit button | <button class="g-recaptcha" data-sitekey="6Lc..." data-callback="onSubmit"> |
a div bound to a button by id | <div class="g-recaptcha" data-sitekey="6Lc..." data-bind="submit-btn" data-callback="onSuccess"> |
| explicit rendering | grecaptcha.render("box", { sitekey: "6Lc...", size: "invisible", callback: onToken }) |
Google's two public demos use the second and third forms, and neither one ever writes the word "invisible". That's why searching the page for invisible misses half the real widgets.
The thing most often mistaken for invisible v2 is reCAPTCHA v3. It also has no checkbox and shows the same badge. The difference is in the script tag and the call: v3 loads api.js?render=<sitekey> and asks for a token with grecaptcha.execute(sitekey, {action: "login"}). v3 needs version=v3 and an action, not invisible=1. If you're unsure which one you have, this comparison of v2, v3 and Enterprise has a detector for all of them. This post stays with invisible v2.
Step 1: find the sitekey and the form in the HTML
When the widget is in the HTML, you need no browser to find it. This finder uses only the standard library. For each widget it returns the sitekey, whether it's invisible, the callback name, and the form it protects: the URL it submits to, the method, and the fields a browser would send with it (disabled inputs and unchecked boxes left out, as a browser does).
"""find_invisible.py: find reCAPTCHA v2 widgets in a page's HTML and the form each one protects.""" import re from html.parser import HTMLParser from urllib.parse import urljoin RENDER = re.compile(r"grecaptcha\.render\(\s*[^,]+,\s*\{([^}]*)\}", re.S) OPT = r"""['"]?{0}['"]?\s*:\s*['"]([^'"]+)['"]""" class WidgetFinder(HTMLParser): def __init__(self): super().__init__() self.forms, self.widgets, self.scripts = [], [], [] self._form = None self._in_script = False def handle_starttag(self, tag, attrs): a = dict(attrs) if tag == "form": self._form = {"action": a.get("action") or "", "method": (a.get("method") or "get").lower(), "id": a.get("id"), "fields": {}} self.forms.append(self._form) elif tag in ("input", "textarea", "select") and self._form is not None: if a.get("name") and "disabled" not in a and a.get("type") not in ("submit", "button", "image"): if a.get("type") in ("checkbox", "radio") and "checked" not in a: return self._form["fields"][a["name"]] = a.get("value") or "" elif tag == "script": self._in_script = True if "data-sitekey" in a: invisible = (a.get("data-size") == "invisible" # div + grecaptcha.execute() or tag == "button" # <button class="g-recaptcha"> or "data-bind" in a) # div bound to a button id self.widgets.append({"sitekey": a["data-sitekey"], "invisible": invisible, "callback": a.get("data-callback"), "form": self._form}) def handle_endtag(self, tag): if tag == "form": self._form = None elif tag == "script": self._in_script = False def handle_data(self, data): if self._in_script: self.scripts.append(data) def find_widgets(html, page_url): p = WidgetFinder() p.feed(html) widgets = p.widgets # explicit rendering: grecaptcha.render(el, {sitekey: "...", size: "invisible"}) for body in RENDER.findall("\n".join(p.scripts)): key = re.search(OPT.format("sitekey"), body) if key: size = re.search(OPT.format("size"), body) widgets.append({"sitekey": key.group(1), "callback": None, "form": None, "invisible": bool(size and size.group(1) == "invisible")}) for w in widgets: form = w.pop("form") or (p.forms[0] if len(p.forms) == 1 else None) w["submit_url"] = urljoin(page_url, form["action"]) if form else None w["method"] = form["method"] if form else None w["fields"] = dict(form["fields"]) if form else {} return widgets On the two demos and a checkbox page for comparison:
import requests from find_invisible import find_widgets for url in ["https://www.google.com/recaptcha/api2/demo?invisible=true", "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php", "https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php"]: html = requests.get(url, timeout=30).text print(find_widgets(html, url)) [{'sitekey': '6LfP0CITAAAAAHq9FOgCo7v_fb0-pmmH9VW3ziFs', 'invisible': True, 'callback': 'onSuccess', 'submit_url': 'https://www.google.com/recaptcha/api2/demo?invisible=true', 'method': 'post', 'fields': {}}] [{'sitekey': '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-', 'invisible': True, 'callback': 'onSubmit', 'submit_url': 'https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php', 'method': 'post', 'fields': {'ex-a': 'foo', 'ex-b': 'bar'}}] [{'sitekey': '6LfW6wATAAAAAHLqO2pb8bDBahxlMxNdo9g947u9', 'invisible': False, 'callback': None, 'submit_url': 'https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php', 'method': 'post', 'fields': {'ex-a': 'foo', 'ex-b': 'bar'}}] The Google demo's fields is empty because every input on that form is disabled, so a browser sends only the token. A widget created by grecaptcha.render() has no element to attach it to a form, so the finder falls back to the page's only form when there is just one. With several forms, check by hand.
When the HTML has no sitekey: read the anchor iframe
Single-page apps and bundled sites often create the widget from JavaScript that a regex won't find. Let the page render, then read the widget's anchor iframe. Every rendered key gets one, and its URL carries the sitekey as k= and the widget type as size=:
// anchors.mjs: list every reCAPTCHA widget a page actually rendered. npm i playwright import { chromium } from "playwright"; const url = process.argv[2]; const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: "load" }); await page.waitForSelector('iframe[src*="/anchor"]', { state: "attached", timeout: 15_000 }); await page.waitForTimeout(2_000); // give late widgets a moment for (const frame of page.frames()) { const u = new URL(frame.url()); if (!u.pathname.endsWith("/anchor")) continue; console.log({ sitekey: u.searchParams.get("k"), size: u.searchParams.get("size"), // "invisible" for v2 invisible AND for v3 enterprise: u.pathname.includes("/enterprise/"), }); } await browser.close(); $ node anchors.mjs "https://www.google.com/recaptcha/api2/demo?invisible=true" { sitekey: '6LfP0CITAAAAAHq9FOgCo7v_fb0-pmmH9VW3ziFs', size: 'invisible', enterprise: false } $ node anchors.mjs https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php { sitekey: '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-', size: 'invisible', enterprise: true } { sitekey: '6Le80pApAAAAANg24CMbhL_U2PASCW_JUnq5jPys', size: 'invisible', enterprise: true } The second run shows two traps. First, that demo loads a second key, an Enterprise score key that isn't part of the form, and it reports size: 'invisible' too, because v3 and score keys always do. Pick the key the form's widget uses (the one in the HTML, or the one tied to the submit request), not the first anchor you see.
Second, the form's own key came back with enterprise: true, yet the page loads it through the classic api.js. In an earlier run of the same script the same day, the same key was served from /recaptcha/api2/anchor and reported false. A page that also loads enterprise.js can do that. So use the anchor path as a hint only: decide Enterprise by whether the key is used through enterprise.js and grecaptcha.enterprise, which the comparison post covers. For this demo the answer is a standard invisible v2 key.
Step 2: solve it with invisible=1
The solve is a normal 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, not the form's action URL |
invisible | 1. The docs mark it as required for invisible widgets: without it the solve can fail |
json | 1, so the answers are JSON |
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 (ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_PAGEURL) stop at once, because retrying won't fix them.
Step 3: post the token with the form
This script ties the steps together. It loads the page, takes the first invisible widget, solves it and posts the form's fields plus g-recaptcha-response to the form's action, which is what the browser would have done after the callback fired. Prerequisites: Python 3.9+ and pip install requests, with find_invisible.py in the same folder.
# solve_invisible.py: solve an invisible reCAPTCHA v2 and post the form, no browser. # Needs: pip install requests (find_invisible.py from above sits next to it) import os import re import sys import time import requests from find_invisible import find_widgets API = "https://ocr.captchaai.com" # 2Captcha-style in.php / res.php API_KEY = os.environ.get("CAPTCHAAI_KEY", "YOUR_API_KEY") UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36") 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_invisible(sitekey, pageurl, timeout=180): 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, "invisible": 1, "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 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: 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") if __name__ == "__main__": page = sys.argv[1] if len(sys.argv) > 1 else \ "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php" if not page.startswith(("http://", "https://")): sys.exit("Pass the full URL of the page that shows the widget") s = requests.Session() s.headers["User-Agent"] = UA widgets = [w for w in find_widgets(s.get(page, timeout=30).text, page) if w["invisible"]] if not widgets: sys.exit("No invisible widget in the HTML: it is a checkbox, or a script renders it (see below).") w = widgets[0] print("widget:", {k: w[k] for k in ("sitekey", "callback", "submit_url")}) token = sys.argv[2] if len(sys.argv) > 2 else solve_invisible(w["Source:https://dev.to/bshahin/invisible-recaptcha-v2-find-the-sitekey-solve-it-with-invisible1-and-submit-the-token-python-i4mRelated posts
DeepSeek's Open-Source "Super-Agent" Codes Without Being Asked
All-in-one platform or a stack you assemble? Where do coaches and educators land?
I Wanted to Live on Coruscant. PAT Scared Me. Now My Alexa Lives on Mute.
Detect What You Can Name: PaliGemma 2 Object Detection with Transformers
When Should an LLM Router Stop Being Sticky? llm-d 1.0 Cache-Affinity Routing, Break-Even Math, and a Simulation
