How to Solve ALTCHA in Playwright and Fill the Hidden Field

solve altcha in playwright - How to Solve ALTCHA in Playwright and Fill the Hidden Field

To solve ALTCHA in Playwright you never ask the widget to do the work. ALTCHA is proof of work rather than recognition: the site hands out a hashing problem and anyone who answers it correctly is let through, with the CPU time as the toll. There is nothing to look at and nothing to click, so the browser is there for the rest of the flow, not for the CAPTCHA. Capture the challenge the page already asked for, hash it on your own machine in milliseconds, then write the answer into the field the form submits. This guide does that in Playwright for Python.

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 Playwright and CapSkip packages installed and at least one browser downloaded.
  • The URL of the page the widget sits on. You do not need a 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 container, a CI runner or another box can reach it over the same API. Step 4 covers which one applies, and both live under connection settings.
# pip install playwright capskip
pip install playwright capskip
playwright install chromium

Step 1: capture the challenge while the page is open

Everything else depends on this one value. The challenge is a small JSON document holding an algorithm, a challenge hash, a salt, a signature and a maximum number, and the site signs it. There are two ways to get hold of it from inside a Playwright run, and which one works depends on how the page is built.

Read the endpoint off the widget

The widget element names the endpoint it will ask. Do not guess the attribute, because it changed between widget generations.

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
# pip install playwright capskip
from playwright.sync_api import sync_playwright

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

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(PAGE_URL)

    # v1 and v2 use challengeurl; v3 and later use challenge.
    widget = page.locator("altcha-widget")
    endpoint = widget.get_attribute("challengeurl")
    if not endpoint:
        endpoint = widget.get_attribute("challenge")

The three interaction styles on the widget’s type attribute, native, checkbox and switch, are purely visual. They submit the same payload and the difference never reaches the solver, so you do not have to work out which one you are looking at. The separate display attribute is visual in the same way. ALTCHA documents all of them in its own widget guide.

Or catch the response the page already fetched

Reading the attribute is not always enough. A v3 widget can hold the challenge data in that attribute rather than a URL, so parse the value as JSON when it starts with a brace, and a widget configured entirely from JavaScript leaves nothing in the markup to read at all. Catching the network response covers that second case, and it hands you the document rather than a pointer to it. Arm the wait before whatever triggers the fetch, or the request happens while nothing is listening.

# Filter out the widget's own script: its URL also contains
# altcha, and it loads before the challenge is ever requested.
is_challenge = lambda r: ("altcha" in r.url
    and "json" in r.headers.get("content-type", ""))

# This fires during navigation only when the widget carries
# auto="onload". Otherwise wrap the click that triggers it.
with page.expect_response(is_challenge) as caught:
    page.goto(PAGE_URL)

challenge = caught.value.json()
print(challenge["algorithm"], challenge["maxnumber"])

Check the widget’s auto attribute before trusting that shape. It decides when verification starts, and only the onload value sends the request during navigation. Left off, or set to onfocus or onsubmit, nothing is fetched until someone touches the form, so arm the wait around a click on the widget instead of around the goto.

Match on a substring rather than the whole URL, but never on the word altcha alone. The path differs per site and often carries a cache-busting query string, so an exact comparison is one reason this never fires, and matching too loosely is the other: the widget script is usually served from a path with altcha in it, it loads first, and the wait then resolves on JavaScript that no JSON parser will accept. Playwright covers the pattern in its network guide.

Step 2: 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 endpoint and the solver fetches the challenge itself. Pass the document and no request goes out at all.

from capskip import CapSkip

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

# The document from step 1, 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

Prefer the inline document when the browser has already seen it. This is the one place where driving a browser changes the advice. A challenge issued to your browser session is the one the site will judge you on, so handing the solver that exact document keeps the two in step. Fetching a second challenge from an endpoint is not wrong, but it means the page is now holding one challenge while you answer another, and on a site that binds the challenge to a session the answer will not match. The option takes a dictionary, serialised for you, or a JSON string if you already have one.

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 that 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 3: write the token into the widget’s 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. In a browser run you fill the field yourself, because nothing verified the widget and it will not have filled anything in.

# Walk up from the submit button so the field lands in the
# form that actually posts, not in the first form on the page.
SET_ALTCHA_FIELD = """({name, token}) => {
    const button = document.querySelector('button[type=submit]');
    const form = button ? button.form : document.querySelector('form');
    let field = form.querySelector('[name=' + name + ']');
    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }
    field.value = token;
}"""

field_name = widget.get_attribute("name") or "altcha"
page.evaluate(SET_ALTCHA_FIELD, {"name": field_name, "token": result["token"]})
page.click("button[type=submit]")

Pick the right form. A signup page often carries several, and appending the field to the first one on the page when the submit button belongs to another means the server never sees a value. The snippet walks up from the submit button for exactly that reason.

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.

One thing a browser adds that a plain HTTP client does not: the page may run its own script over the form. If the submit button stays disabled, the page is waiting to hear that the widget succeeded rather than reading the field. Two honest answers there, and which one you take depends on how much of the page you want to keep. You can find what the page listens for and satisfy it, or you can skip the button and post the form’s fields directly with the browser’s cookies, which is usually shorter and always more stable.

Step 4: where the solver runs once Playwright moves to CI

The samples above 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 what decides the address is where the test process runs. Playwright makes that easy to forget, because the browser is often somewhere else already.

Move that process into the official Playwright Docker image or onto a CI runner and loopback now points at the container, where nothing is listening, so the first solve raises a NetworkException. Switch CapSkip to Server mode and it listens on your network address or public IP instead, and the container connects over the same HTTP API. 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.

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

Read the host and port from the environment so one script works in both places. The client also picks up CAPSKIP_HOST, CAPSKIP_PORT and CAPSKIP_API_KEY on its own if you would rather not pass them.

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 os
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
from playwright.sync_api import sync_playwright

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

SET_ALTCHA_FIELD = """({name, token}) => {
    const button = document.querySelector('button[type=submit]');
    const form = button ? button.form : document.querySelector('form');
    let field = form.querySelector('[name=' + name + ']');
    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }
    field.value = token;
}"""

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

def is_challenge(r):
    return "altcha" in r.url and "json" in r.headers.get("content-type", "")

with sync_playwright() as p:
    page = p.chromium.launch(headless=True).new_page()

    # Catch, solve and submit with nothing slow in between.
    with page.expect_response(is_challenge) as caught:
        page.goto(PAGE_URL)

    try:
        result = solver.altcha(url=PAGE_URL, challenge_json=caught.value.json())
    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")

    field_name = page.locator("altcha-widget").get_attribute("name") or "altcha"
    page.fill("input[name=email]", "someone@example.com")
    page.evaluate(SET_ALTCHA_FIELD, {"name": field_name, "token": result["token"]})
    page.click("button[type=submit]")

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.

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 Playwright CAPTCHA solver page.

Common errors and what they mean

What you seeCauseFix
Both widget attributes come back as NoneThe widget was configured entirely from JavaScript, so neither name exists in the markupCatch the response instead, which does not depend on the markup
get_attribute stalls for 30 seconds and then raisesThe widget element never appeared, so the locator waited out its default timeoutCheck the selector against the rendered page, then fall back to catching the response
The response wait times outThe widget has no auto attribute set to onload, so nothing was ever fetched, or the wait was armed after the navigationWrap whatever triggers the fetch, and open the context manager before it
A JSON parse error on the caught responseThe wait resolved on the widget’s own script, whose URL also contains altchaAdd the JSON content type to the filter
An ApiException on a challenge you captured moments agoThe inline challenge had already expired, so the solver refused it rather than hashing itRecapture and solve in one breath, or pass the endpoint so the solver can refetch
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
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 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
The form posts but the server reports a missing altcha valueThe hidden field was appended to a different form on the pageQuery the form the submit button belongs to
A TimeoutException naming 120 secondsThe solver did not answer inside the default polling timeoutCheck the solver is running and not saturated. Raising the ceiling only delays the same answer
The submit button never becomes enabledThe 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 solved with CPU and no browser is involved in the answer. If the only reason you opened Playwright was the CAPTCHA, close it: fetch the challenge with an HTTP client and post the token back, which the plain Python guide walks through. Playwright 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 a site that renders its markup in script.

Can Playwright reach the solver from Docker or GitHub Actions?

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 container, 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. The solver stays on hardware you own either way, so nothing about the licence or the number of solves changes.

How long does an ALTCHA token stay valid?

Not long, and the site decides. Some windows close inside two minutes. When one expires the site refuses the answer with a bare verification failure that looks exactly like a wrong answer, and nothing in the response tells you which of the two happened. So do not collect challenges in advance, do not park a token in a variable while the browser walks three more pages, and never hold one while a person fills in a form. A fresh solve costs milliseconds, which is cheaper than working out why a stale one failed.

Will Playwright’s own timeouts cut the solve short?

No, because the solve is not a Playwright call. The default 30 second action and navigation timeouts cover clicks, waits and page loads, and your solver call is ordinary Python sitting between two of them. The ceiling that applies is the client’s own 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

Catch the challenge the widget asked for, either off the attribute or off the response, pass that document to the one ALTCHA method along with the page URL, and write the token into a hidden field, named by the widget’s own name attribute, in the form that actually submits. Do not touch the token on the way. Keep the capture, the solve and the submit close together, because the window can close inside two minutes and an expired challenge is indistinguishable from a wrong answer. Switch to Server mode as soon as the script stops sharing a machine with the solver.

One last thing that changes how you design the retry. Because this route to a local captcha solver computes the proof of work on a machine you already own, a discarded challenge costs a few milliseconds of your own CPU and nothing else, so you can afford to reload the page and take a fresh one rather than nursing a stale token through a long run.