How to Solve CAPTCHA in Camoufox Using Main World Eval

A Camoufox captcha step looks like every other one: read the sitekey, send it to a solver, write the token into the page. The third move is where Camoufox is different. It runs the JavaScript you hand to evaluate in an isolated scope that the page cannot see, and an isolated scope cannot change the page’s DOM. Your write returns without an error, the textarea stays empty, and the form fails validation. The fix is one launch option and a two-character prefix.
What you need
- Python 3.10 or newer. Camoufox 0.5 pins Playwright itself, so let pip resolve it.
- The Camoufox browser downloaded once with the fetch command. It is a Firefox build, not Chromium.
- The page URL of the protected form. The sitekey is read at runtime.
- CapSkip running in Local mode when the script and the solver share a machine, or in Server mode when they do not. Both are described under connection settings.
# The geoip extra is optional and worth having if you use proxies. pip install -U capskip "camoufox[geoip]" # Downloads the browser itself. Run once per machine. camoufox fetch
Why the usual token injection does nothing here
Camoufox is a patched Firefox with a thin Python wrapper around Playwright. The wrapper class is a Playwright context manager, so what you get back from it is an ordinary Playwright browser object and every locator, click and navigation you already know works unchanged. That is the good news and it is most of the library.
The exception is script execution. Camoufox runs all JavaScript in an isolated scope that is invisible to the page, which is the whole reason it exists: a site cannot see the automation poking at it. Reading is unaffected, because Playwright’s own locator methods travel over the browser protocol rather than through that scope. Writing is affected, and this is the sentence to remember: an isolated scope cannot modify the DOM. A token assignment there is discarded quietly.
Camoufox’s answer is a main world escape hatch. Pass main_world_eval when you launch, then prefix any script that has to touch the real page with mw: and it runs in the page’s own scope. Two things come with that. The site can detect code running there, so use it for the injection and nothing else. And you cannot return element references out of the main world, only values that survive as JSON.
Step 1: launch with the main world enabled
The option is off by default and it has to be set at launch time. There is no way to switch it on for a single call later.
# pip install camoufox[geoip]
from camoufox.sync_api import Camoufox
with Camoufox(main_world_eval=True, headless=True) as browser:
page = browser.new_page()
page.goto("https://example.com/page-with-recaptcha")
# browser is a normal Playwright Browser from here on.
print(page.title())Two neighbouring options are worth knowing before you go further. humanize moves the cursor along a human-looking path, taking up to about 1.5 seconds to cross the window, which matters if you click the widget yourself rather than injecting a token. And disable_coop drops the Cross-Origin-Opener-Policy so that elements inside cross-origin iframes, the Cloudflare Turnstile checkbox among them, can be clicked at all.
Step 2: read the sitekey off the page
The sitekey sits on the host document, not inside the widget iframe. Google’s markup puts it on a container as a data-sitekey attribute, and a plain Playwright locator reads it. No main world prefix is needed, because this path never goes through the isolated scope.
# Locators wait by default, so this doubles as a wait
# condition for a widget that renders late.
holder = page.locator("div.g-recaptcha")
holder.wait_for(state="attached", timeout=15000)
sitekey = holder.get_attribute("data-sitekey")
print(sitekey) # 6LxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSome sites never expose the sitekey on the host page and pass it only in the widget iframe URL. Read it out of the query string in that case.
# Fallback: the k= parameter on the anchor iframe.
from urllib.parse import urlparse, parse_qs
src = page.locator("iframe[src*='recaptcha/api2/anchor']").get_attribute("src")
sitekey = parse_qs(urlparse(src).query)["k"][0]Check the value before you spend a solve on it. An empty sitekey travels all the way to the solver and comes back as ERROR_GOOGLEKEY, a long way from the locator that actually failed.
Step 3: solve it on your own machine
The Python SDK talks to CapSkip on port 8080 and returns the token as a plain string. One method covers reCAPTCHA v2, Invisible, Enterprise and v3, with the variants passed as options rather than as separate calls.
# pip install capskip from capskip import CapSkip solver = CapSkip(host="127.0.0.1", port=8080) # Invisible v2 takes invisible=1, Enterprise takes enterprise=1, # and v3 takes version="v3" with an action. result = solver.recaptcha(sitekey=sitekey, url=PAGE_URL) token = result["code"] # the g-recaptcha-response value
Turnstile and GeeTest have their own methods and take the same shape. Turnstile additionally returns the user agent the solve was made with, and a challenge page will reject the token unless you send that user agent back with it. Full parameter lists live in the CapSkip API documentation.
Camoufox also ships an async class, and the Python SDK’s AsyncCapSkip is a genuine asyncio implementation rather than an alias for the synchronous one. Pair them when you drive several contexts at once, so a solve on one page does not stall the others.
Step 4: inject the token in the main world
Here is the part that is specific to this browser. The response textarea is hidden with display:none, so no automation tool can type into it and you have to assign the value with JavaScript. Prefix the script with mw: so it runs in the page’s own scope, and build the string with json.dumps rather than an f-string, because a JSON string literal is also a valid JavaScript string literal, quoting and escaping included.
# The mw: prefix is what makes this write land.
import json
page.evaluate(
"mw:document.getElementById('g-recaptcha-response').value = "
+ json.dumps(token)
)
page.click("button[type=submit]")Verifying that it landed has one wrinkle. You cannot hand an element back out of the main world, so return a value instead of the node. A length is fine and it tells you exactly what you want to know.
# Return a number, never the element itself.
length = page.evaluate(
"mw:document.getElementById('g-recaptcha-response').value.length"
)
print(length) # 0 means the injection did not landIf the site defines a success callback instead of reading the textarea when the form submits, call that callback after setting the value. This is the case where the main world is not merely convenient but required, because a function the page defined does not exist in the isolated scope at all. Read the function name out of the page’s own markup rather than guessing it. Either submission style is still ordinary reCAPTCHA v2 underneath, and both of them are written up in detail on the reCAPTCHA v2 solver page.
Full working example
Everything above in one script. The solver is created once, the browser closes itself when the block ends, and the sitekey is checked before a solve is spent on it.
# pip install capskip camoufox[geoip]
import json
from camoufox.sync_api import Camoufox
from capskip import CapSkip
PAGE_URL = "https://example.com/page-with-recaptcha"
solver = CapSkip(host="127.0.0.1", port=8080)
with Camoufox(main_world_eval=True, headless=True) as browser:
page = browser.new_page()
page.goto(PAGE_URL)
holder = page.locator("div.g-recaptcha")
holder.wait_for(state="attached", timeout=15000)
sitekey = holder.get_attribute("data-sitekey")
if not sitekey:
raise RuntimeError("Widget found but data-sitekey was empty.")
result = solver.recaptcha(sitekey=sitekey, url=PAGE_URL)
page.evaluate(
"mw:document.getElementById('g-recaptcha-response').value = "
+ json.dumps(result["code"])
)
page.click("button[type=submit]")
page.wait_for_load_state("networkidle")
print(page.url) # where you land after submittingRunning the solver on another machine
Camoufox tends to end up on a bigger box than the one you wrote the script on, and there is a platform detail to get right when it does. Passing headless as the string virtual starts an Xvfb display, which is a Linux-only feature: on Windows and macOS that value raises a not-supported error instead. Plain headless works everywhere, so use the boolean unless you are deliberately on Linux.
The solver does not have to travel with the browser. CapSkip has two connection modes. Local binds to 127.0.0.1 and answers that device only, which is the right setting while you are writing the script on the machine the app runs on. Server binds to your network or public IP, so a scraping VM, a second workstation or a Linux box running Camoufox calls the same Windows machine over the API. A static public IP keeps that address stable. Nothing in the code changes except the host, and nothing about the cost changes either, because it is still your hardware.
# Same SDK, same call. Only the host moves. solver = CapSkip(host="10.0.0.12", port=8080, apiKey="YOUR_API_KEY")
Turn on key validation once the solver listens on a network address, and give each machine its own key so one can be revoked without touching the others. Both modes are walked through in the CapSkip setup guide.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| The token is set but the form still fails | The write ran in the isolated scope and was discarded | Launch with main_world_eval and prefix the script with mw: |
| Evaluate raises about an unsupported return | A node reference was returned out of the main world | Return a length or a string instead of the element |
| The callback function is undefined | Page globals do not exist in the isolated scope | Call it from the main world with the same prefix |
| The Turnstile checkbox cannot be clicked | It sits in a cross-origin iframe | Launch with disable_coop, or inject a token instead of clicking |
| ERROR_GOOGLEKEY | An empty sitekey reached the solver | Assert the value before calling recaptcha |
| NetworkException | CapSkip is not running, or the host is wrong | Start the app, or point host at the server address |
| TimeoutException | The solve outlasted recaptchaTimeout | Raise it above the default of 300 seconds |
| Virtual display not supported | headless was set to virtual off Linux | Use headless=True instead |
FAQ
Does main world eval make me easier to detect?
Anything running in the main world is visible to the page, so yes, in principle. In practice the exposure is one assignment that lasts microseconds and looks identical to what the widget’s own script does when a human passes the challenge. Keep every other script in the isolated scope, do the injection in one call rather than several, and you are not handing over much.
Can I reuse my existing Playwright code?
Almost all of it. The launcher hands back a real Playwright browser, so locators, contexts, routes and waits behave as they always did. The two things to revisit are any call that writes to the DOM through evaluate, which needs the prefix, and anything that assumed Chromium, since this is Firefox. The wider picture for that engine is on the Playwright CAPTCHA solver page.
Should I click the widget instead of injecting a token?
Only for Turnstile, and only sometimes. A Turnstile checkbox in managed mode can pass on its own if the browser looks convincing, which is what Camoufox is for, and clicking it needs the Cross-Origin-Opener-Policy dropped first. A reCAPTCHA checkbox click just opens an image challenge, so there is nothing to gain there. Details on the widget side are on the Cloudflare Turnstile solver page.
My crawler runs on a Linux VPS. Where does CapSkip go?
On a Windows machine you control, with Server mode switched on. The VPS then calls it over the API exactly as it would any internal service, so Camoufox and the solver do not need to share an operating system or even a network segment. Point the host argument at that address, enable key validation, and give the VPS its own key.
The short version
Launch Camoufox with main_world_eval switched on, read the sitekey with an ordinary locator, solve it against CapSkip on 127.0.0.1:8080, then inject the token through an evaluate call prefixed with mw: and submit. The prefix is the whole trick, because without it your write lands in a scope the page never sees. For the rest of the Python landscape, including Selenium and Playwright, see the Python CAPTCHA solver page.
One more thing is worth knowing before you scale a crawl up. CapSkip is a local captcha solver that runs on hardware you already own, so a run that retries a thousand pages costs exactly what a run that retries ten costs.
