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 generation | Attribute that names the challenge |
|---|---|
| v1 and v2 | challengeurl for an endpoint, with a separate challengejson attribute when the challenge is inline |
| v3 and later | challenge, 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 runs | Which connection mode |
|---|---|
| On the CapSkip machine, driving a local browser | Local mode. 127.0.0.1 is genuinely correct |
| On another box on the same network | Server mode, on that machine’s private address |
| In a Playwright container, a CI runner or a VPS | Server mode with a static public IP and a firewall rule |
| Locally, but connecting to a remote browser | Local 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 see | Cause | Fix |
|---|---|---|
| Both widget attributes come back as None | The widget was configured entirely from JavaScript, so neither name exists in the markup | Catch the response instead, which does not depend on the markup |
| get_attribute stalls for 30 seconds and then raises | The widget element never appeared, so the locator waited out its default timeout | Check the selector against the rendered page, then fall back to catching the response |
| The response wait times out | The widget has no auto attribute set to onload, so nothing was ever fetched, or the wait was armed after the navigation | Wrap whatever triggers the fetch, and open the context manager before it |
| A JSON parse error on the caught response | The wait resolved on the widget’s own script, whose URL also contains altcha | Add the JSON content type to the filter |
| An ApiException on a challenge you captured moments ago | The inline challenge had already expired, so the solver refused it rather than hashing it | Recapture 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 solved | The challenge expired between the solve and the submit | Capture, solve and submit with nothing slow in between |
| ERROR_CAPTCHA_UNSOLVABLE inside an ApiException, in about a third of a second | The challenge uses Argon2id or scrypt | Nothing to retry. Those two are refused by design |
| A NetworkException on the first solve | CapSkip is not running, or the script is in a container and pointed at loopback | Start CapSkip, then decide between Local mode and Server mode |
| The form posts but the server reports a missing altcha value | The hidden field was appended to a different form on the page | Query the form the submit button belongs to |
| A TimeoutException naming 120 seconds | The solver did not answer inside the default polling timeout | Check the solver is running and not saturated. Raising the ceiling only delays the same answer |
| The submit button never becomes enabled | The page gates it on its own script seeing the widget succeed | Post the form fields directly, or satisfy whatever the page listens for |
| A ValidationException on the call | Neither challenge option was supplied, or an option ALTCHA does not take was passed | Pass 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.
- What the type is and how it works: the ALTCHA solver page.
- The same job without a browser: solving ALTCHA in plain Python.
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.
