How to Solve ALTCHA in Selenium Without Network Interception

solve altcha in selenium - How to Solve ALTCHA in Selenium Without Network Interception

To solve ALTCHA in Selenium you never wait for the widget to finish. ALTCHA is proof of work rather than recognition: the site hands out a hashing problem, and the client has to find the number that satisfies it before it is let through. There is nothing to look at and nothing to click, so the browser is there for the rest of the flow and not for the CAPTCHA. Selenium has no one-call equivalent of Playwright’s response wait, and this is one of the rare cases where that costs you nothing: the widget names its own challenge endpoint in an attribute, which is all the solver needs.

What you need

  • CapSkip 1.2.6 or later running on a Windows machine. ALTCHA support arrived in that release, so an older build has no method to call.
  • Python 3.10 or newer, with the Selenium and CapSkip packages installed, plus a matching browser.
  • The URL of the page the widget sits on. There is no sitekey, because ALTCHA does not have one.
  • An address for the solver. Local mode answers on 127.0.0.1 for that device only, and Server mode listens on your network address or public IP so a Grid node, a CI runner or another box can reach it over the same API. Step 5 covers which one applies, and both live under connection settings.
# pip install selenium capskip
pip install selenium capskip

Step 1: read the challenge endpoint off the widget

The widget element names the endpoint it will ask for its challenge. Which attribute holds it depends on the widget generation, so read the page source rather than assuming.

Widget generationAttribute that names the challenge
v1 and v2challengeurl for an endpoint, with a separate challengejson attribute when the challenge is inline
v3 and laterchallenge, and that one attribute takes either a URL or the challenge data itself

Two Selenium habits matter here and neither is obvious. Selenium does not auto-wait for an element, so query the widget before the page has put it in the DOM and you get a NoSuchElementException rather than a retry. And the ordinary attribute call is a hybrid: it tries the JavaScript property of that name first and only falls back to the markup. A web component defines properties for its own attributes, so what comes back depends on the widget build rather than on the HTML you can read. The DOM attribute call has no such fallback and returns what the markup says, which is what you want. The Python API reference spells the difference out on the WebElement page.

# pip install selenium capskip
from urllib.parse import urljoin
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

PAGE_URL = "https://example.com/signup"

driver = webdriver.Chrome()
driver.get(PAGE_URL)

# Nothing auto-waits in Selenium, so wait for the element.
widget = WebDriverWait(driver, 15).until(
    EC.presence_of_element_located((By.CSS_SELECTOR, "altcha-widget"))
)

# v1 and v2 use challengeurl or challengejson; v3 uses challenge.
value = (widget.get_dom_attribute("challengeurl")
         or widget.get_dom_attribute("challengejson")
         or widget.get_dom_attribute("challenge"))
if value is None:
    raise SystemExit("no challenge attribute on the widget")

Two of those three attributes give you the document rather than a pointer to it, so test what you got before treating it as a URL. A URL needs one more step of its own: the markup usually carries a same-origin path such as a leading slash and a folder name, and an HTTP client cannot fetch that on its own. Resolve it against the page URL first.

import json

challenge, endpoint = None, None
if value.lstrip().startswith("{"):
    # challengejson, or a v3 challenge holding the data inline.
    challenge = json.loads(value)
else:
    # A path in the markup becomes an absolute URL here.
    endpoint = urljoin(PAGE_URL, value)

The three interaction styles on the widget’s type attribute, native, checkbox and switch, are purely visual and never reach the solver, and the separate display attribute is visual in the same way. ALTCHA documents all of them in its own widget guide.

Step 2: fetch the challenge with the browser’s session

You can skip this step whenever the endpoint is public, and plenty are. Hand the URL to CapSkip and it fetches the challenge itself. The step exists because some sites bind the challenge to the session that asked for it, and the solver fetches from your machine with no cookies, so what it gets back belongs to nobody. Then the answer is correct and the site still refuses it.

The fix is to fetch the challenge inside the browser’s session and pass the document instead of the pointer. Copy the cookies Selenium is already holding into an HTTP client, carry the same user agent, and ask for it yourself.

import requests

if endpoint:
    session = requests.Session()
    for cookie in driver.get_cookies():
        session.cookies.set(cookie["name"], cookie["value"],
                            domain=cookie["domain"])
    session.headers["User-Agent"] = driver.execute_script(
        "return navigator.userAgent")

    # Referer matters on sites that check where the ask came from.
    challenge = session.get(endpoint,
                            headers={"Referer": PAGE_URL}).json()

A JSON decode error on that line is the useful failure. It means the endpoint answered with HTML, normally a login page or a consent wall, which tells you the request went out as a stranger and that this step was the right call. Start the solve straight after the fetch. The window is short and the clock starts when the site issues the challenge, not when you get round to using it.

Step 3: hand the challenge to the one ALTCHA method

One method, described in full on the ALTCHA solver page, and it takes the challenge either way round. Pass the document and no request goes out at all. Pass the endpoint and the solver fetches it, including a fresh one if the job sat in the queue long enough for the first to expire.

from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

# The document from step 2, so nothing is fetched twice.
result = solver.altcha(url=PAGE_URL, challenge_json=challenge)

# Or hand over the endpoint and let CapSkip fetch it.
# result = solver.altcha(url=PAGE_URL, challenge_url=endpoint)

print(result["token"])    # base64 payload for the form field
print(result["number"])   # the counter that satisfied it

Two keys in that result exist for ALTCHA alone. The token key holds the base64 payload the form wants, and the number key holds the counter that solved the challenge. The code key carries the same string as the token, so either one works, but the token key is named for the field it goes into. The GeeTest keys and the Turnstile user agent are absent here.

Which algorithms the solver covers

The legacy scheme is covered with SHA-1, SHA-256, SHA-384 and SHA-512, and proof-of-work v2 with PBKDF2 and iterative SHA. PBKDF2 is the default ALTCHA itself recommends, so that is the large majority of live sites. Argon2id and scrypt are the exceptions and they are refused rather than attempted: a challenge asking for either comes back in about a third of a second as ERROR_CAPTCHA_UNSOLVABLE and is never retried, because a memory-hard function is not something a retry fixes. On this type that result points at the algorithm rather than at an unreadable image, and the error code has a guide of its own.

Step 4: write the token into the hidden field

The widget submits its payload in a hidden input whose name comes from its own name attribute, which defaults to altcha. Read that attribute rather than assuming, the same way you read the challenge one. Nothing verified the widget in this run, so nothing filled that field in and you are filling it yourself.

Selenium can pass a WebElement straight into a script, which makes the button itself the cleanest way to find the right form. Hand the submit button over and read its form property: a signup page often carries several forms, and appending the field to the first one on the page when the button belongs to another means the server never sees a value.

SET_FIELD = """
const form = arguments[0].form || document.querySelector('form');
let field = form.querySelector('[name="' + arguments[1] + '"]');
if (!field) {
    field = document.createElement('input');
    field.type = 'hidden';
    field.name = arguments[1];
    form.appendChild(field);
}
field.value = arguments[2];
"""

button = driver.find_element(By.CSS_SELECTOR, "button[type=submit]")
field_name = widget.get_dom_attribute("name") or "altcha"

driver.execute_script(SET_FIELD, button, field_name, result["token"])
button.click()

Pass the string straight through. The token is base64 of a JSON document whose fields are covered by the server’s HMAC signature, so anything that looks like tidying up breaks it: trimming whitespace, decoding and re-encoding, or rebuilding the JSON with the keys in another order. Some integrations read the payload out of a JSON body field rather than a form field, and the widget can also be configured to deliver it in a cookie, so check what the page’s own submit sends and mirror that.

If the submit button never becomes clickable, the page is gating it on its own script hearing that the widget succeeded, rather than on the field holding a value. Two honest answers there. Find what the page listens for and satisfy it, or skip the button and post the form’s fields directly with the cookies the browser already has, which is usually shorter and always more stable.

Step 5: where the solver runs once your script moves

The samples use 127.0.0.1 because that is right while your script and CapSkip share a machine. The solver is called by your Python code, not by the browser and not by the page, so the address depends on where the test process runs and not on where Chrome runs. Selenium makes that easy to get backwards, because the browser is so often somewhere else already.

Where the Python process runsWhich connection mode
On the CapSkip machine, driving a local browserLocal mode. 127.0.0.1 is genuinely correct
On your machine, driving a remote WebDriver or a Grid nodeLocal mode. The browser never talks to the solver
On another box on the same networkServer mode, on the solver machine’s private address
In a container, on a CI runner or on a VPSServer mode with a static public IP and a firewall rule

Switch CapSkip to Server mode and it listens on your network address or public IP instead of loopback, and the runner connects over the same HTTP API it would use locally. A static public IP is recommended when the route crosses the internet, with a firewall rule that allows only the addresses you expect. Server mode changes where the solver listens and nothing else: it is still your hardware and it is still unmetered. Running the tests themselves across a Grid has its own set of traps, covered separately in the Selenium Grid guide.

Read the host and port from the environment so one script works in both places. The client does not read CAPSKIP_HOST or CAPSKIP_PORT by itself, so pass them to the constructor, as the full example below does.

One ALTCHA-specific note on proxies. A proxy is supported here, but it is used only for the challenge fetch. There is no browser session to route through it, so it has no effect on the proof of work itself, and it does nothing at all when you pass the challenge document inline.

Full working example

import json, os, requests
from urllib.parse import urljoin
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

PAGE_URL = "https://example.com/signup"

SET_FIELD = """
const form = arguments[0].form || document.querySelector('form');
let field = form.querySelector('[name="' + arguments[1] + '"]');
if (!field) {
    field = document.createElement('input');
    field.type = 'hidden';
    field.name = arguments[1];
    form.appendChild(field);
}
field.value = arguments[2];
"""

solver = CapSkip(
    host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.environ.get("CAPSKIP_PORT", 8080)),
)

driver = webdriver.Chrome()
try:
    driver.get(PAGE_URL)
    widget = WebDriverWait(driver, 15).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "altcha-widget"))
    )
    value = (widget.get_dom_attribute("challengeurl")
             or widget.get_dom_attribute("challengejson")
             or widget.get_dom_attribute("challenge"))
    if value is None:
        raise SystemExit("no challenge attribute on the widget")

    if value.lstrip().startswith("{"):
        challenge = json.loads(value)
    else:
        endpoint = urljoin(PAGE_URL, value)
        session = requests.Session()
        for cookie in driver.get_cookies():
            session.cookies.set(cookie["name"], cookie["value"],
                                domain=cookie["domain"])
        session.headers["User-Agent"] = driver.execute_script(
            "return navigator.userAgent")
        challenge = session.get(endpoint,
                                headers={"Referer": PAGE_URL}).json()

    try:
        result = solver.altcha(url=PAGE_URL, challenge_json=challenge)
    except ApiException:
        raise SystemExit("refused: Argon2id, scrypt, or an expired challenge")
    except NetworkException:
        raise SystemExit("solver unreachable: check host and connection mode")
    except TimeoutException:
        raise SystemExit("no answer inside defaultTimeout")

    button = driver.find_element(By.CSS_SELECTOR, "button[type=submit]")
    field_name = widget.get_dom_attribute("name") or "altcha"
    driver.find_element(By.NAME, "email").send_keys("[email protected]")
    driver.execute_script(SET_FIELD, button, field_name, result["token"])
    button.click()
finally:
    driver.quit()

All four exceptions derive from CapSkipError, so catching that one instead handles every failure the SDK can raise in a single block. Catch the specific ones when the response differs, as above, and CapSkipError when it does not. Note which TimeoutException you are catching: the CapSkip one and Selenium’s own share a name, so import one of them under an alias if both are in scope.

The other types work the same way from the same client. reCAPTCHA and Turnstile take a sitekey and a page URL, GeeTest takes a gt value, a challenge and the page URL, and image solving takes a file path, a URL or base64. Every method the package exposes is listed on the Python CAPTCHA solver page, and the wider browser story lives on the Selenium CAPTCHA solver page.

Common errors and what they mean

What you seeCauseFix
NoSuchElementException on the widgetThe element had not been inserted into the DOM yet, which happens when script builds the markup, and Selenium does not wait on its ownWrap the lookup in an explicit wait for the element to be present
All three attribute reads come back as NoneThe widget was configured entirely from JavaScript, so none of the three names exists in the markupRead the configuration out of the page’s own script, or fall back to the endpoint you can see in the network panel
An empty attribute value where the page source clearly has oneThe ordinary attribute call returned a property the web component defines, which is emptyUse the DOM attribute call, which reads the markup and has no property fallback
MissingSchema from the HTTP client, naming a value that starts with a slashThe markup holds a same-origin path, and the DOM attribute call returns it exactly as writtenResolve it against the page URL before fetching, and before passing it to the solver
The attribute value starts with a braceA challengejson attribute, or a v3 widget carrying the document inline rather than a URLParse it as JSON and pass it as the inline challenge
A JSON decode error when you fetch the endpointThe endpoint answered with HTML, so the request arrived without the browser’s sessionCopy the cookies and user agent across before fetching
An ApiException on a challenge you captured moments agoThe inline challenge had already expired, so the solver refused it rather than hashing itFetch and solve in one breath, or pass the endpoint so the solver can refetch, as long as that endpoint is not session bound
ERROR_CAPTCHA_UNSOLVABLE inside an ApiException, in about a third of a secondThe challenge uses Argon2id or scryptNothing to retry. Those two are refused by design
A bare verification failure, with a token your log shows was solvedThe challenge expired between the solve and the submitCapture, solve and submit with nothing slow in between
A NetworkException on the first solveCapSkip is not running, or the script is in a container and pointed at loopbackStart CapSkip, then decide between Local mode and Server mode
A TimeoutException naming 120 secondsThe solver did not answer inside the default polling timeout, which ALTCHA uses rather than the longer reCAPTCHA oneCheck the solver is running and not saturated. Raising the ceiling only delays the same answer
The form posts but the server reports a missing altcha valueThe hidden field was appended to a different form on the pageRead the form property off the submit button, as the snippet does
The submit button stays disabledThe page gates it on its own script seeing the widget succeedPost the form fields directly, or satisfy whatever the page listens for
A ValidationException on the callNeither challenge option was supplied, or an option ALTCHA does not take was passedPass the endpoint or the document, and drop anything else

FAQ

Do I need a browser to solve ALTCHA at all?

No. ALTCHA is a hashing problem, so it is answered with CPU and no browser is involved in the answer. If the CAPTCHA was the only reason you opened Selenium, close it: fetch the challenge with an HTTP client and post the token back, which the plain Python guide walks through. Selenium earns its place when the rest of the flow needs a real page, such as a login that sets cookies, a multi-step form, or markup that only exists after script runs.

Why does the widget attribute come back empty?

Because the ordinary attribute call is a hybrid. It returns the JavaScript property when the element defines one and only falls back to the markup, and a web component defines properties for its own attributes, so an empty property wins over the value you can see in the page source. The DOM attribute call has no such fallback and returns what the markup says. The other reason is that you are reading the wrong name for the generation in front of you, so try all three before deciding the attribute is missing.

Can a CI runner or a container reach the solver?

Yes, over Server mode. Switch CapSkip from loopback to your network address or public IP under connection settings, then point the host environment variable at it. The runner and the solver then speak the same HTTP API they would on one machine. Use a static public IP when the route crosses the internet and restrict it with a firewall rule. A remote Grid node is a different question and usually needs nothing at all, because the node runs the browser while your script, which is what calls the solver, stays where you started it.

Will Selenium’s own timeouts cut the solve short?

No, because the solve is not a WebDriver command. An implicit wait, a page load timeout and a script timeout all cover calls Selenium sends to the browser, and your solver call is ordinary Python sitting between two of them. The ceiling that applies is the client’s default polling timeout of 120 seconds, which ALTCHA uses rather than the longer reCAPTCHA one because it is CPU work and not a browser session. Watch instead for a limit wrapped around the whole test, such as a per-test plugin timeout or a CI job limit.

The short version

Wait for the widget, read its challenge attribute with the DOM attribute call, resolve a path against the page URL, and fetch that endpoint inside the browser’s session if the site binds it to one. Pass the document or the URL to the one ALTCHA method along with the page URL, then write the token into the hidden field, named by the widget’s own name attribute, in the form the submit button belongs to. Do not touch the token on the way. Keep the fetch, the solve and the submit close together, because the window can close inside two minutes and an expired challenge looks exactly like a wrong answer.

One last thing that changes how you design the retry. Because this route to an unlimited captcha solver computes the proof of work on hardware you already own, a discarded challenge costs a few milliseconds of your own CPU and nothing else, so reloading the page for a fresh one is always cheaper than nursing a stale token through a long test.