I spent three days trying to get a headless Playwright bot past reCAPTCHA. I assumed the captcha was the hard part. It wasn't — the IP reputation was. Here are the five things that actually moved the needle, in the order I learned them.
1. Your datacenter IP is already burned before the page loads
The first run failed on a clean datacenter IP before the captcha even rendered. The challenge never appeared; the request just came back flagged. That's the reputation layer doing its job: the moment the datacenter ASN shows up, the score is already too low for any token to matter.
The fix was a residential proxy pinned to the target region. Same browser, same script, same sitekey — one IP swap later and the challenge finally showed up on screen.
2. Solve the token server-side, not with a click simulator
Once the challenge was visible, I dropped the idea of clicking tiles with Playwright. You don't need to interact with the widget at all. You send the sitekey and the page URL to a solving API, get back a response token, and inject it into the hidden field:
token = solver.solve(sitekey=sitekey, url=page.url)
page.evaluate(
"k => document.getElementById('g-recaptcha-response').value = k",
token,
)
Then submit the form as a human would. The token is what the backend validates; how the checkbox looks is irrelevant.
3. The token is bound to the IP that requested it
This is the one that cost me a full evening. I solved the token on one connection and submitted the form through a different proxy. Rejected every time. reCAPTCHA binds the token to the IP that opened the challenge, so the solve and the submit have to come from the same exit node.
I moved the solver call to run from the same residential session as the browser, and the rejections stopped cold.
4. Reuse the session, not just the token
A fresh cookie jar gets challenged on every single page load. Once a session passes one challenge, the cookie carries that trust for a while. I started persisting the browser context between runs instead of tearing it down:
context = browser.new_context(storage_state="state.json")
# ... do work ...
context.storage_state(path="state.json")
Warm sessions hit fewer challenges, which means fewer tokens, which means fewer paid solves and a much faster loop.
5. A proxy that dies mid-session is worse than no proxy
I had a residential provider that rotated the exit node every few minutes. Every rotation was a new IP, a new reputation, a new challenge. The "helpful" rotation was actively breaking my solves. I switched to sticky sessions — one IP held for the whole run — and the error rate dropped to near zero.
The lesson I'd compress it all into: the captcha is a reputation check, not a puzzle. Spend your effort on the IP and the session, and let a solver handle the token. That order of priorities saved me more time than any widget interaction ever did.
If you're automating anything with reCAPTCHA, start with sticky residential sessions and server-side token injection — the solver and the clicks come last.
Top comments (0)