Your script asks for a page and gets HTTP 403 with a "Just a moment…" page instead of content. That is the Cloudflare challenge, and it is solved with a cookie, not a form token. This post assembles the complete Playwright flow: solve the challenge through your own proxy, replay cf_clearance in the same proxied browser, and prove the protected route really loads. A shorter second example shows the same replay from a plain HTTP client.
What the solve returns
A CloudflareChallengeCookie result carries:
token— thecf_clearancecookie value Cloudflare set when the check passed;cookies— the rest of the solve's cookie jar when the solver returns one, one JSON object per line, each with its own domain and path;userAgent— the browser identity the solve presented.
cf_clearance is bound to the exit IP and the user agent that earned it: a request that arrives from a different IP or with a different user agent is treated as cookieless again. One solved cookie covers the whole origin for as long as Cloudflare honors it, not only the single URL you sent. It addresses the challenge page only — a Turnstile widget inside a normal page is a different task with a different result.
One proxy for the solve and the replay
This task type has no proxyless variant: the create call needs proxy, and the replay must travel through the same proxy with the solve's user agent. Use one static exit for the whole flow — rotating exits break the binding between the cookie and the IP.
The proxy URL uses http:// or socks5:// (Proxies documents both forms). Chromium, and therefore Playwright, takes the proxy as a server plus separate credentials, and it cannot authenticate against a SOCKS5 proxy. For browser runs prefer an http:// proxy with credentials; keep socks5:// for clients that support it natively.
The Playwright flow
// cloudflare-challenge-playwright.js - solve Cloudflare Challenge and open the protected page
// with the cf_clearance cookie.
// Install: npm install playwright && npx playwright install chromium
// Run: node cloudflare-challenge-playwright.js
const { chromium } = require('playwright');
const { setTimeout: sleep } = require('node:timers/promises');
// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The challenged page, exactly as it shows the challenge.
const TARGET_URL = 'https://example.com/page';
// The proxy for the solve and the replay: http://user:pass@host:port or socks5://host:port.
const PROXY_URL = 'http://username:password@proxy.example.com:8080';
// A selector that matches only after the site accepted the result.
const SUCCESS_SELECTOR = '.PASTE_ACCEPTED_SELECTOR';
const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;
// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
return data;
}
async function solve(task) {
const created = await call('/createTask', { clientKey: CLIENT_KEY, task });
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
const result = await call('/getTaskResult', {
clientKey: CLIENT_KEY,
taskId: created.taskId,
});
if (result.status === 'ready') return result.solution;
await sleep(3000);
}
throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}
// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
return Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
);
}
// Chromium takes the proxy as a server plus credentials rather than one URL.
// It cannot send credentials to a SOCKS5 proxy; use an http:// proxy for browser runs.
function proxyOption(proxyUrl) {
const url = new URL(proxyUrl);
return {
server: `${url.protocol}//${url.host}`,
...(url.username
? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) }
: {}),
};
}
async function main() {
const browser = await chromium.launch({ proxy: proxyOption(PROXY_URL) });
try {
const solution = await solve({
type: 'CloudflareChallengeCookie',
websiteURL: TARGET_URL,
proxy: PROXY_URL,
});
// Replay with the user agent the solve presented, through the same proxy.
const context = await browser.newContext(
solution.userAgent ? { userAgent: solution.userAgent } : {},
);
await context.addCookies([
{
name: 'cf_clearance',
value: solution.token,
domain: new URL(TARGET_URL).hostname,
path: '/',
secure: true,
},
]);
const page = await context.newPage();
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
await page.locator(SUCCESS_SELECTOR).first().waitFor({ timeout: 15000 });
console.log('The site served the protected page.');
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Step by step:
- Chromium launches through your proxy from the first request, so the page that loads is the challenged one your visitors and scripts actually see.
- The task is created with the challenged URL exactly as the browser shows it — the route that served the interstitial, not the site root. Challenges apply per route.
- The solved
cf_clearancevalue is added as a cookie on the challenged origin (secure, path/). When the result carriescookies, those entries belong in the same context so the session keeps the rest of the jar. - The replay opens a context whose user agent is the solve's
userAgentand navigates to the challenged URL again. SUCCESS_SELECTOR— something only the real page contains — must appear within fifteen seconds. That wait, not the task finishing, is the success check.
Node.js
A browser context is not always wanted. The same result replays from a plain HTTP client as a Cookie header plus the solve's user agent, through the same proxy:
// cloudflare-challenge-node.js - solve Cloudflare Challenge and request the protected page with
// the cf_clearance cookie.
// Install: npm install undici
// Run: node cloudflare-challenge-node.js
const { fetch: proxiedFetch, ProxyAgent } = require('undici');
const { setTimeout: sleep } = require('node:timers/promises');
// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The challenged page, exactly as it shows the challenge.
const TARGET_URL = 'https://example.com/page';
// The proxy for the solve and the replay: http://user:pass@host:port or socks5://host:port.
const PROXY_URL = 'http://username:password@proxy.example.com:8080';
// Text that appears only in a response the site accepted.
const SUCCESS_TEXT = 'PASTE_ACCEPTED_MARKER';
const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;
// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
return data;
}
async function solve(task) {
const created = await call('/createTask', { clientKey: CLIENT_KEY, task });
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
const result = await call('/getTaskResult', {
clientKey: CLIENT_KEY,
taskId: created.taskId,
});
if (result.status === 'ready') return result.solution;
await sleep(3000);
}
throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}
// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
return Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
);
}
async function main() {
// undici ProxyAgent speaks http:// proxies; use a socks5 agent package for socks5://.
const dispatcher = new ProxyAgent(PROXY_URL);
const solution = await solve({
type: 'CloudflareChallengeCookie',
websiteURL: TARGET_URL,
proxy: PROXY_URL,
});
const headers = { Cookie: `cf_clearance=${solution.token}` };
// Replay with the user agent the solve presented, through the same proxy.
if (solution.userAgent) headers['User-Agent'] = solution.userAgent;
const response = await proxiedFetch(TARGET_URL, { headers, dispatcher });
const text = await response.text();
if (!response.ok || !text.includes(SUCCESS_TEXT)) {
throw new Error(`The site did not accept the result (HTTP ${response.status}).`);
}
console.log('The site accepted the result.');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The undici ProxyAgent speaks http:// proxies; for a socks5:// proxy you would attach a SOCKS agent package instead. The success text works like the selector above: pick a string that appears only in the served page, never in the interstitial.
Python
pip install requests and python cloudflare-challenge-python.py replay the same way: requests sends an http:// proxy natively — install its SOCKS extra for socks5:// — and the cf_clearance cookie, the solve's userAgent and the success text check ride one request.
# cloudflare-challenge-python.py - solve Cloudflare Challenge and request the protected page
# with the cf_clearance cookie.
# Install: pip install requests
# Run: python cloudflare-challenge-python.py
import time
import requests
# Your SurSolver API key.
CLIENT_KEY = "YOUR_API_KEY"
# The challenged page, exactly as it shows the challenge.
TARGET_URL = "https://example.com/page"
# The proxy for the solve and the replay: http://user:pass@host:port or socks5://host:port.
PROXY_URL = "http://username:password@proxy.example.com:8080"
# Text that appears only in a response the site accepted.
SUCCESS_TEXT = "PASTE_ACCEPTED_MARKER"
API = "https://api.sursolver.com"
POLL_ATTEMPTS = 60
def call(path, body):
# API errors are HTTP 200 with errorId 1; anything else is a transport failure.
response = requests.post(f"{API}{path}", json=body, timeout=30)
response.raise_for_status()
data = response.json()
if data["errorId"] != 0:
raise RuntimeError(f'{data["errorCode"]}: {data["errorDescription"]}')
return data
def solve(task):
created = call("/createTask", {"clientKey": CLIENT_KEY, "task": task})
for _ in range(POLL_ATTEMPTS):
result = call("/getTaskResult", {"clientKey": CLIENT_KEY, "taskId": created["taskId"]})
if result["status"] == "ready":
return result["solution"]
time.sleep(3)
raise TimeoutError(f'task {created["taskId"]} was not ready in {POLL_ATTEMPTS} polls')
PROXIES = {"http": PROXY_URL, "https": PROXY_URL}
def main():
solution = solve({
"type": "CloudflareChallengeCookie",
"websiteURL": TARGET_URL,
"proxy": PROXY_URL,
})
headers = {}
# Replay with the user agent the solve presented, through the same proxy.
if solution.get("userAgent"):
headers["User-Agent"] = solution["userAgent"]
response = requests.get(
TARGET_URL,
headers=headers,
cookies={"cf_clearance": solution["token"]},
proxies=PROXIES,
timeout=30,
)
if not response.ok or SUCCESS_TEXT not in response.text:
raise SystemExit(f"The site did not accept the result (HTTP {response.status_code}).")
print("The site accepted the result.")
if __name__ == "__main__":
main()
When the flow fails
| Symptom | Likely cause | What to check |
|---|---|---|
| The interstitial appears again on replay | A different exit IP or user agent than the solve used | Use one static proxy for solve and replay; send solution.userAgent as User-Agent |
createTask is rejected naming proxy |
The task was sent without proxy details | Add proxy — this type has no proxyless name |
| The solve reports no challenge on the URL | The route you sent is not the challenged one | Send the URL that actually returned 403 with "Just a moment…", not the site root |
| Playwright cannot connect through the proxy | A SOCKS5 proxy with credentials | Chromium cannot authenticate SOCKS5; switch the browser run to an http:// proxy |
| The interstitial shows a "Verify you are human" checkbox | An interactive challenge variant | Interactive challenges can take more than one round; run another solve if the first cookie does not clear the route |
ERROR_TASK_TIMEOUT or ERROR_FAILED |
A final solve-side failure | Submit a new task; Errors lists the codes |
If the replay still answers 403 with cf_clearance set, compare it with a request that passes: Headers, User-Agent and TLS tells the cookie apart from the client, and Proxies for CAPTCHA solving covers sticky sessions and exit-IP checks.
Charging follows the standard lifecycle: a task is charged when it is accepted, and a failed task returns its charge once (Task lifecycle).
The Cloudflare Challenge solver guide covers recognition and parameter details, and the Challenge API reference shows the exact request and solution fields. To tell the challenge page apart from Turnstile, Waiting Room and JSD, see the Cloudflare comparison post.