Node.js Turnstile solver: clearing Cloudflare Turnstile from Node
To solve a Cloudflare Turnstile widget from Node.js you send the target's sitekey and page URL to a solver API, get a challenge token back, and write that token into the page's cf-turnstile-response field before submitting the form. Node 18 and later ship a global fetch, so the whole thing runs with no dependencies at all.
How solving works from Node
Turnstile renders in an iframe you cannot reach into from Node. What you can do is reproduce the challenge out of band and hand the result back to the page. Three things move: the sitekey, the page URL, and the token.
- **Sitekey** — the public key the widget was rendered with. On implicit rendering it sits on the container as
data-sitekey. On explicit rendering (turnstile.render()) there is no attribute, so take it from your own front-end source or config. It is not a secret; it ships to every visitor. - **Page URL** — the URL the widget appears on. Turnstile binds tokens to the hostname, so a token minted for
staging.example.comwill not validate onapp.example.com. - **Token** — a short string the widget normally produces. It expires after 300 seconds and
siteverifyaccepts each one exactly once, so mint it late and use it immediately.
Before you write any of this, check whether you need it. If the property is yours, Cloudflare publishes dummy sitekeys that make the widget deterministic: 1x00000000000000000000AA always passes, 2x00000000000000000000AB always fails, 1x00000000000000000000BB always passes in invisible mode, and 3x00000000000000000000FF forces an interactive challenge. Point staging at one and the widget stops blocking your suite for free.
Solving is for what the test keys cannot cover: a staging environment whose Turnstile configuration you do not control, a vendor sandbox, or synthetic monitoring that has to exercise the real production login path.
Solve only against properties you own or are explicitly authorised to test. That boundary is in our Acceptable Use Policy and we enforce it.
Zero dependencies: global fetch on Node 18+
fetch, AbortSignal.timeout() and AbortController are all globals from Node 18 onward. That is everything you need to call the API. Save this as solve.mjs — the .mjs extension buys you ESM and top-level await.
// solve.mjs — Node 18+, zero dependencies. // Run: SOLVEGATE_KEY=sk_test_... node solve.mjs import { randomUUID } from "node:crypto"; const API = "https://api.solvegate.io"; const KEY = process.env.SOLVEGATE_KEY; if (!KEY) throw new Error("set SOLVEGATE_KEY"); export async function solveTurnstile({ sitekey, url, timeoutMs = 60_000 }) { const res = await fetch(`${API}/v1/solve`, { method: "POST", headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json", // Retrying the same request with this header cannot double-charge you. "idempotency-key": randomUUID(), }, body: JSON.stringify({ gate: "turnstile", sitekey, url }), signal: AbortSignal.timeout(timeoutMs), }); const body = await res.json().catch(() => ({})); if (res.status === 429) { // Retry-After is sent on every 429, in whole seconds. Honour it. const err = new Error("rate_limited"); err.retryAfter = Number(res.headers.get("retry-after")) || 1; throw err; } if (!res.ok) { // Documented envelope: { error: { code, message, billed } } const { code = "error", message = res.statusText, billed = false } = body.error ?? {}; throw Object.assign(new Error(`${code}: ${message}`), { status: res.status, code, billed }); } if (body.mode === "sandbox") { console.warn("sandbox key: deterministic response, never cleared a real gate"); } return body; // { id, status, token, solve_ms, expires_at, mode, meter, billed } } const solve = await solveTurnstile({ sitekey: "0x4AAAAAAAAA_target", url: "https://staging.example.com/login", }); console.log(solve.status, `${solve.solve_ms}ms`, solve.token);
POST /v1/solve blocks until the challenge clears, which is typically under 1.5 seconds, and returns the solve object with status: "solved". Pass async: true instead and you get a pending solve back immediately, which you poll with GET /v1/solve/{id} — reading a solve is free and never billed. Use gate: "waf" rather than "turnstile" when the obstacle is a full Cloudflare WAF challenge page instead of a widget on your form.
The solvegate npm SDK
The SDK is the same HTTP call with the tedious parts already written: 429 backoff that reads Retry-After, network retries, the error envelope unpacked into a typed exception, and a polling helper. It has no dependencies of its own and is ESM-only, so import it — from CommonJS, use a dynamic await import("solvegate").
npm install solvegate # Node 18+, zero dependencies, ships its own .d.ts// solve.ts — TypeScript, or drop the annotations for plain .mjs import { SolveGate, SolveGateError, type Solve } from "solvegate"; const sg = new SolveGate(process.env.SOLVEGATE_KEY!, { timeoutMs: 45_000, // per-request cap maxRetries: 3, // 429s and network errors; Retry-After wins over backoff }); export async function turnstileToken(sitekey: string, url: string): Promise<string> { try { const solve: Solve = await sg.solve({ gate: "turnstile", sitekey, url }); if (solve.status !== "solved" || solve.token === null) { throw new Error(`solve ${solve.id} ended as ${solve.status}`); } if (solve.meter === "sandbox") { throw new Error("sandbox key — swap in a live key before trusting this token"); } return solve.token; } catch (err) { if (err instanceof SolveGateError) { // .code / .status / .billed come straight off the error envelope. switch (err.code) { case "balance_empty": case "spend_cap_reached": throw new Error(`out of credit (${err.code}) — top up, then retry`); case "solve_timeout": throw new Error("gate did not clear; not billed, safe to retry"); case "unknown_sitekey": throw new Error(`sitekey ${sitekey} rejected — re-read it off the page`); } } throw err; } } // Fire-and-poll, for jobs you do not want to block on. export async function turnstileTokenAsync(sitekey: string, url: string): Promise<Solve> { const pending = await sg.solve({ gate: "turnstile", sitekey, url, async: true }); return sg.wait(pending.id, { intervalMs: 750, timeoutMs: 30_000 }); }
sg.wait() loops sg.retrieve() until the solve leaves pending or the deadline passes, and returns the solve either way — check status on what comes back rather than assuming success. The first 1,000 solves are free, and a failed solve is never billed on either path.
fetch or SDK: which to use
Both talk to the same endpoint. The difference is how much retry and error plumbing you own.
| Global fetch | solvegate SDK | |
|---|---|---|
| Install | Nothing. Node 18+ | npm install solvegate, no transitive deps |
| 429 handling | Yours to write | Reads Retry-After, falls back to exponential backoff |
| Network retries | Yours to write | 3 by default, configurable |
| Async polling | Write the loop | sg.wait(id, { intervalMs, timeoutMs }) |
| Errors | Parse body.error yourself | SolveGateError with .code, .status, .billed |
| Types | Declare them (see below) | Bundled .d.ts |
| Module system | Anything | ESM only (import, or dynamic import from CJS) |
| Best for | One call inside an existing HTTP wrapper | Test suites and workers that run this constantly |
TypeScript types for the raw fetch path
If you stay on fetch, res.json() returns any and every field access is unchecked. Declare the response shape once and the compiler carries it everywhere. This mirrors what the API actually returns.
// solvegate-types.ts export type Gate = "turnstile" | "waf"; export type SolveStatus = "pending" | "solved" | "failed"; export interface Solve { id: string; status: SolveStatus; gate: Gate; token: string | null; // null until status === "solved" solve_ms: number | null; expires_at: number | null; // unix seconds mode: "live" | "sandbox"; // sandbox = sk_test_ key, no real gate cleared meter: "credits" | "pass" | "sandbox"; billed: boolean; error_code: string | null; // set only when status === "failed" error_message: string | null; } export interface ApiErrorBody { error: { code: string; message: string; billed: boolean }; } export function isSolved(s: Solve): s is Solve & { token: string } { return s.status === "solved" && s.token !== null; }
The isSolved type guard is the piece that pays for itself: after it, s.token is a string and TypeScript stops making you assert it at every call site. The npm SDK exports the same Solve, Gate and SolveStatus types, minus mode — branch on meter === "sandbox" there.
Handing the token to the page with Playwright
A token on its own does nothing. The widget's job in a real browser is to fill a hidden <input name="cf-turnstile-response"> inside your form; when you solve out of band, you fill it yourself.
// test.mjs — npm install playwright import { chromium } from "playwright"; import { solveTurnstile } from "./solve.mjs"; const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto("https://staging.example.com/login"); // Implicit rendering puts the sitekey on the container. Explicit rendering // does not — read it from your own config in that case. const sitekey = await page.locator("[data-sitekey]").first().getAttribute("data-sitekey"); const { token } = await solveTurnstile({ sitekey, url: page.url() }); await page.evaluate((t) => { const field = document.querySelector('input[name="cf-turnstile-response"]'); if (!field) throw new Error("no cf-turnstile-response input — widget not rendered yet"); field.value = t; }, token); await page.fill("#email", "qa@example.com"); await page.fill("#password", process.env.TEST_PASSWORD); await page.click('button[type="submit"]'); await page.waitForURL("**/dashboard"); await browser.close();
Two failure modes to expect. If the input is missing, the widget script has not run yet — await page.waitForSelector('input[name="cf-turnstile-response"]') before evaluating. And if your front-end reads the token from a success callback rather than from the form field, setting value changes nothing; call that callback with the token instead. The same pattern ports to Puppeteer unchanged, swapping page.locator(...).getAttribute() for page.$eval.
Errors worth handling in CI
A solver call inside a test suite fails differently from application code: nobody is watching, and a vague failure burns an afternoon. Branch on code, not on the HTTP status alone.
| Status | code | What it means | What to do |
|---|---|---|---|
| 401 | invalid_key | Key missing, revoked, or malformed | Fix the env var. Retrying will not help |
| 402 | balance_empty | Prepaid credit exhausted | Top up. Fail the job loudly rather than looping |
| 402 | spend_cap_reached | Workspace monthly cap hit | Raise the cap or wait for the month to roll |
| 422 | unknown_sitekey | No live gate at that sitekey plus URL | Re-read both off the page. Deterministic — do not retry |
| 429 | rate_limited | Above your key's ceiling | Sleep for Retry-After seconds, then retry. Never billed |
| 503 | target_unavailable | Too many recent failures for this target | Back off and retry shortly. Never billed |
| 504 | solve_timeout | The gate did not clear in time | Retry. Never billed |
Only 429 and the transient 5xx codes deserve an automatic retry, and the SDK already does those two for you. Wrap the rest in an assertion that fails the test with the code in the message — future-you reading a CI log wants to see unknown_sitekey, not Error: request failed.
One last thing that catches people: the 300-second token lifetime is wall clock from the moment it is minted. If your test solves during setup and submits the form four minutes later after a slow fixture, siteverify returns timeout-or-duplicate and you will blame the solver. Solve immediately before the submit.
Common questions
No. Node 18 and later expose fetch, AbortController and AbortSignal.timeout() as globals, which is everything the API call needs. The solvegate npm package is optional; it adds typed errors, Retry-After handling and a polling helper, and has no dependencies of its own.
With implicit rendering it is the data-sitekey attribute on the widget container, readable with page.locator("[data-sitekey]").getAttribute("data-sitekey"). With explicit rendering there is no attribute, so take it from your own front-end source or config. The sitekey is public either way — it ships to every visitor.
Into the hidden <input name="cf-turnstile-response"> that Turnstile adds to your form, then submit normally. If your application reads the token from the widget's success callback instead of the form field, invoke that callback with the token.
Turnstile tokens expire 300 seconds after they are issued, and Cloudflare's siteverify accepts each one exactly once. A token minted during test setup and submitted several minutes later returns timeout-or-duplicate. Solve immediately before the submit.
No. Failed solves are never billed, and neither are 429 rate-limit responses or reads of GET /v1/solve/{id}. The first 1,000 solves are free; after that credits run from $0.40 per 1,000 down to $0.075 at volume.
No. SolveGate covers Cloudflare Turnstile — managed, non-interactive and invisible modes — and Turnstile WAF challenge pages. reCAPTCHA, hCaptcha, GeeTest, FunCaptcha and AWS WAF are not supported.
Related
More in Guides
Automating a gate you own or are authorised to test?
// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free