Selenium fills a form, clicks the button, and the page answers with a challenge instead of a dashboard. This guide is about handling that moment well: recognising which CAPTCHA the page is showing, choosing the route that fits the job, and dropping in the code that gets a real token when you need one. It is the general handling guide; the full API integration, in Python and Node, lives in our Selenium integration guide.
What Selenium runs into
A CAPTCHA appears because the site's rules decided the visit looks
automated, and Selenium hands them the evidence: the
navigator.webdriver flag, a user agent with no plugins, form fills
and submissions faster than any human manages. The defences differ by
provider but the shape is the same:
- reCAPTCHA v2 renders a checkbox inside
iframe[title*="reCAPTCHA"]and stores the answer in a hiddeng-recaptcha-responsefield. - reCAPTCHA v3 has no widget at all: the page calls
grecaptcha.execute()and scores the token invisibly. - Cloudflare Turnstile renders its own widget and stores the answer
in
cf-turnstile-response. - Interstitials — Cloudflare, Amazon WAF — replace the page you asked for with a challenge page before your form ever loads.
The practical consequence: the script needs the token the page expects, not a battle with a widget it cannot see. The reliable unit of work is the token, and there are three honest ways to get it.
When you control the application
If it is your own site, the right answer is to keep automation away from the challenge entirely: disable CAPTCHA verification in staging, use the provider's documented test keys in CI, or allow-list the automation addresses. This keeps your tests about the product, not about anti-bot defences, and it is where most Selenium suites should stop.
When you do not: read the challenge first
Working with third-party sites, the first job is knowing what you hit. The DOM tells you:
- the
data-sitekeyattribute on the widget element (or on thecf-turnstilediv for Turnstile) is the site key the API needs; - reCAPTCHA v3 puts its key in the script URL as
?render=SITEKEYand the action in the page'sgrecaptcha.executecall; - the hidden response field names above tell you which answer the form expects.
The key alone is not enough: a solver also needs the full URL of the page hosting the widget, and that has to be the URL your driver is actually on. Reading both from the page keeps the solve and the submit in the same session, which is the difference between a token the site accepts and one it quietly ignores.
The solving API, in brief
For third-party pages the working route is a solving service: the challenge details go out, a token comes back, the script puts it where the page expects it. With the official EndCaptcha client the request is one call — the client uploads and polls for you:
import json
from endcaptcha import HttpClient
client = HttpClient("YOUR_USERNAME", "YOUR_PASSWORD")
captcha = client.decode(
type=4,
token_params=json.dumps({
"googlekey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
"pageurl": "https://www.example.com/signup",
}),
)
token = captcha["text"]
type=4 is reCAPTCHA v2; type=12 with sitekey instead of
googlekey handles Turnstile, and the two field sets are documented on
the v2, v3
and Turnstile type pages. If you poll
by hand, right after upload the API answers UNSOLVED_YET:/poll/<id>
while the job waits, the poll answers ? while a solver works, and the
token text when it is solved; poll no faster than every three seconds.
Injecting it takes one script call, and the response fields are in the main document — only the widget's visual part lives in the iframe:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver.execute_script(
"""
const field = document.querySelector(
'[name="g-recaptcha-response"]');
if (field) {
field.value = arguments[0];
field.dispatchEvent(new Event('input', {bubbles: true}));
field.dispatchEvent(new Event('change', {bubbles: true}));
}
""",
token,
)
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, 'button[type="submit"]'))
).click()
The complete flow — Node variant, the pageurl and proxy rules, the
fetch-based forms — is covered in the
Selenium integration guide.
This guide keeps to the handling decisions around it.
Keeping the session credible
Tokens come from the API; trust with the page is still built by the driver. Four habits cut down how often you meet a challenge at all:
- Be consistent. The same browser flags, the same user agent and the same proxy between the solve and the submit. A run that works headed and fails headless is usually a fingerprint that changed mid-flow, not an API problem.
- Keep the session warm. Reuse the driver's cookies across steps so the site sees one returning visitor instead of five new ones. Selenium keeps them by default within a driver; persist them if the flow restarts.
- Move like a script that is not a bot. Real waits instead of
sleep()guesses:WebDriverWaitfor elements, ordinary typing into fields, scrolls before clicks. Detection looks at the shape of the interaction, and instant form fills are a shape no human has. - Match the network to the task. If the driver submits through a proxy, pass the same proxy with the token parameters so the solver comes from the address that submits. Mismatched addresses are the most common cause of "the API returned a token and the site rejected it".
Troubleshooting
| What you see | What it usually means | What to do |
|---|---|---|
| The widget never appears | The page loads it lazily or behind a challenge page | Wait for the iframe element, or solve the interstitial first |
TimeoutException clicking inside the widget |
The click target is in the cross-origin iframe | Do not click the widget; solve and inject the response field |
| Token accepted by the API, rejected by the page | pageurl differs from driver.current_url, or the token is older than about two minutes |
Read the URL off the driver, solve, inject and submit in one pass |
StaleElementReferenceException after injecting |
The form re-rendered | Re-find the submit button inside the same explicit wait |
| Works locally, challenged in the pipeline | Headless flag or user agent differs | Keep the same profile flags in both places |
The answer is ? for too long |
A solver is still working | Keep polling every three seconds; the client caps it for you |
Questions developers ask
Is clicking the checkbox ever enough? Not on production traffic. The widget is cross-origin and the click pattern is exactly what the check detects; the token route is the one that holds.
Do test keys work with a solving service? They prove plumbing, not solving: the services reject provider test keys, so CI can use them for form wiring while real tokens come from real keys.
How long does a real solve take? The service publishes a
seven-second SLA; in practice the poll returns in seconds when the
queue is free, and decode() caps the wait for you.
Can I run this with Selenium Grid? Yes — the token is produced outside the browser, so any node can inject it. Keep the pageurl on the public URL of the page, not the node's address.
Where does the rest of the integration live? The Selenium integration guide has the full Python and Node flow, and the Playwright guide covers the same solving pattern for another driver. The quick start has the cURL version, and creating an account takes a minute.

