Local HTTP service that solves Cloudflare Turnstile widgets and clears Cloudflare "Just a moment..." JS / interactive challenges. Built on Pydoll (CDP-direct Chromium automation) for the Turnstile widget path and Byparr (Camoufox + playwright-captcha) for the JS-challenge path, behind a single unified HTTP API.
POST /solve— solves a Turnstile widget and returns the token. Uses a warm Pydoll Chromium instance with a persistent user-data-dir. Uses Pydoll's built-inexpect_and_bypass_cloudflare_captchashadow-root detection + click for the actual interaction.POST /solve-challenge— clears a Cloudflare JS or interactive challenge and returns the final URL, title, cookies (filtered to the target domain), user-agent, and full HTML. Delegates to a bundled Byparr instance (FlareSolverr-compatible protocol); falls back to the in-process Pydoll path if the proxy is unreachable.- Browser is warmed at startup so the first request doesn't pay a cold-start penalty.
- Sanitised error responses with stable
error_codefield — internal Playwright/Camoufox stack traces stay in the server log only. - Bounded request body (default 64 KB) and
siteurlvalidation (http/https only) to prevent the headless browser from being pointed at attacker-controlledfile:///chrome://URLs. - Structured single-block log per request with per-step progress output.
- Async HTTP server (aiohttp); clients may send requests in parallel, solves are serialised internally to avoid CF difficulty escalation.
docker compose up -dbrings up both services with health-gated startup.
Pydoll talks CDP directly over a WebSocket — no WebDriver, no
Playwright Node driver, no navigator.webdriver flag. That means the
driver-side bug class that killed the previous Camoufox+Playwright
stack on real Cloudflare pages (pageError.location.url crash) can't
reach us. Pydoll also ships a built-in Cloudflare Turnstile bypass
(shadow-root inspection + click) which we wire into every /solve.
For the heavier JS-challenge path, Byparr ships its own Camoufox-based
worker that actively tracks CF protocol changes; it clears those pages
in 10–20 s on a cold profile. Byparr exposes a FlareSolverr-compatible
API (POST /v1 with cmd: request.get), so swapping to FlareSolverr
later is one env var.
Cloudflare binds most production Turnstile sitekeys to the origin they
were issued for. /solve therefore navigates to the real siteurl
and uses the widget rendered there. If the page doesn't render the
requested sitekey, we return an error rather than forging a fake host
page — a forged origin won't yield a valid token anyway. Pass the URL
that actually hosts the sitekey.
- Docker and Docker Compose, or
- Python 3.11+ (tested on 3.13) and a Chromium binary
(
/usr/bin/chromiumor pointed viaCHROME_PATH).
git clone git@github.com:cv3inx/turnstile-solver.git
cd turnstile-solver
docker compose up -dThis starts two containers:
violetics-solver(this service) on:9988violetics-byparr(Camoufox-based CF challenge worker) on the internal compose network only — not exposed on the host.
The solver waits for Byparr to report healthy before starting.
Check it:
curl http://localhost:9988/healthRemove the byparr service and the CHALLENGE_PROXY_URL env from
docker-compose.yml, or set CHALLENGE_PROXY_URL="". /solve-challenge
will then run the pure-Pydoll path. Expect higher latency and more
timeouts on hosts where Cloudflare's interactive challenge page resists
headless Chromium.
docker build -t violetics-solver .
docker run -d --name byparr --restart unless-stopped \
ghcr.io/thephaseless/byparr:latest
docker run -d --name solver --shm-size=1gb \
--link byparr \
-e CHALLENGE_PROXY_URL=http://byparr:8191 \
-e CHALLENGE_PROXY_KIND=byparr \
-p 9988:9988 \
-v solver-profile:/tmp/ts_profile \
violetics-solver--shm-size=1gb is required — Firefox crashes with the default 64 MB
/dev/shm. The volume mount preserves the Cloudflare cookie profile
across container restarts.
sudo apt install chromium # or set CHROME_PATH to your binary
pip install -r requirements.txt
# Byparr is optional; without it /solve-challenge runs the in-process
# Pydoll path only.
export CHALLENGE_PROXY_URL=http://localhost:8191 # if running Byparr locally
export CHALLENGE_PROXY_KIND=byparr
python service.pyEnvironment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
9988 |
HTTP port |
MAX_WORKERS |
8 |
Max concurrent in-flight HTTP requests (solves are serialised internally) |
MAX_BODY_BYTES |
65536 |
Max accepted request body size |
LOG_LEVEL |
INFO |
Python logging level (DEBUG, INFO, WARNING, ERROR) |
CHALLENGE_PROXY_URL |
(unset) | Base URL of a Byparr/FlareSolverr instance. When set, /solve-challenge delegates to it. |
CHALLENGE_PROXY_KIND |
byparr |
byparr (timeouts in seconds) or flaresolverr (timeouts in ms). Auto-set when only FLARESOLVERR_URL is provided. |
FLARESOLVERR_URL |
(unset) | Back-compat alias for CHALLENGE_PROXY_URL with KIND=flaresolverr |
TS_PROFILE_DIR |
/tmp/ts_profile |
Persistent Chromium user-data-dir |
HEADLESS |
true |
true runs Chromium with --headless=new. false opts into headed mode (Xvfb at :99). |
CHROME_PATH |
/usr/bin/chromium |
Path to the Chromium binary (overridable for headed/Chrome-stable runs). |
All endpoints accept and return JSON.
Solves a Turnstile widget.
Request:
{
"sitekey": "0x4AAAAAAC3x1HiBz5IFyj7s",
"siteurl": "https://www.example.com/",
"timeout": 45,
"action": "login",
"cdata": "optional-customer-data"
}action and cdata are optional and forwarded to the widget when present.
timeout is clamped to [5, 180] seconds; default 45.
Response (200):
{
"token": "1.abc...xyz",
"elapsed": 6.14
}Response (4xx / 5xx):
{
"error": "solve timeout",
"error_code": "timeout",
"elapsed": 45.2
}error_code is one of:
| Code | HTTP | Meaning |
|---|---|---|
bad_request |
400 | Validation failure (missing field, bad URL, oversize) |
timeout |
504 | Solve did not finish within timeout seconds |
browser_error |
503 | Browser closed / navigation failure — caller should retry |
solver_error |
500 | Internal failure (sanitised — see server log for detail) |
Clears a Cloudflare JS or interactive challenge and returns the page state.
When CHALLENGE_PROXY_URL is configured, the request is proxied to Byparr
transparently — callers see the same response shape either way.
Request:
{
"siteurl": "https://api.example.com/docs",
"timeout": 45
}Response (200):
{
"url": "https://api.example.com/docs/",
"title": "Example API",
"user_agent": "Mozilla/5.0 ...",
"cookies": [
{
"name": "cf_clearance",
"value": "...",
"domain": ".example.com",
"path": "/",
"expires": 1811226000
}
],
"html": "<!doctype html>...",
"elapsed": 15.52
}Use the returned cf_clearance cookie together with user_agent when
proxying the protected API. Both must match — Cloudflare rejects the
cookie if the user-agent differs from the one that earned it.
The solver auto-retries extensionless paths with a trailing slash, since
Byparr/FlareSolverr is sensitive to that for some sites (e.g. /docs
times out, but /docs/ clears).
Returns service status counters.
{
"status": "ok",
"mode": "byparr",
"proxy_url": "http://byparr:8191",
"in_flight": 0,
"solved": 30,
"errors": 1,
"challenges": 11
}Returns extended counters: uptime, total requests, success rate, latency
percentiles (avg / p50 / p95), and the last 50 request events. Used by
the built-in playground at /.
Each request produces one block with real-time progress steps:
「 NEW REQUEST 」
» ID : 29241879
» FROM : 172.20.0.1
» POST : /solve
» URL : https://www.example.com/
» KEY : 0x4AAAAAAC3x1H...
[29241879] opening tab for https://www.example.com/
[29241879] route intercepted https://www.example.com/
[29241879] token obtained (4.3s)
» SPEED : 4.31s
» STATUS : 200 - token 1.1Tqrqdr...26cb55 (538 chars)
For JS-challenge requests routed through Byparr:
「 NEW REQUEST 」
» ID : cdd14513
» FROM : 172.20.0.1
» POST : /solve-challenge
» URL : https://api.example.com/docs
[cdd14513] delegating to byparr -> http://byparr:8191
[cdd14513] byparr cleared (15.5s, cookies=1)
» SPEED : 15.52s
» STATUS : 200 - title='Example API' cookies=1 html=74236b
All output is written to stdout. Internal library warnings are suppressed.
The service accepts many HTTP requests in parallel, but Cloudflare escalates difficulty when multiple tabs on the same profile request a token for the same sitekey at once. Solves are therefore serialised inside the service.
Typical throughput on a warm browser:
- Turnstile (
/solve), always-pass demo: 4–8 s end-to-end - Turnstile (
/solve), real sitekey: depends entirely on the target page; the solver itself adds ~2 s on top of CF's own clearance time - JS challenge via Byparr: ~15 s per solve (single Byparr worker)
- JS challenge via in-process Pydoll, warm profile: 2–5 s
Scaling beyond single-browser throughput requires multiple independent solver instances, each with its own warm profile and IP.
This service is intended to run inside a trusted network. Before exposing it publicly:
- Put it behind a reverse proxy (Caddy / nginx) for TLS, CORS, and IP allow-listing.
- Add an auth layer at the proxy — every
/solveis a real browser tab and is expensive, so unauthenticated public access is an abuse vector. - Add a per-IP rate limit at the proxy.
The persistent profile volume (/tmp/ts_profile) holds Cloudflare
cookies. Treat it as sensitive — anyone with the volume can reuse those
clearances.
solver.py Core browser automation (Pydoll) + Byparr delegation
service.py aiohttp HTTP wrapper, request logging, validation
requirements.txt Python dependencies (pydoll-python, aiohttp)
Dockerfile Container image (Python + Chromium + Xvfb)
docker-compose.yml Compose stack: solver + byparr
entrypoint.sh Container entrypoint (Xvfb fallback, starts service)
web/ Built-in playground UI
MIT