Simplescraper
Skip to content

How to solve a Turnstile / reCAPTCHA challenge programmatically

How to solve a Turnstile / reCAPTCHA challenge programmatically

Updated 2026-06-25 · 6 min read

If your Puppeteer script reaches a form or a gate on a site you are authorized to automate and stalls on a Cloudflare Turnstile widget or a reCAPTCHA v2 checkbox, the page is waiting for a token your headless browser cannot produce on its own. The widget loads, the request to submit never completes, and the script either times out or loops on the same screen. Solving the puzzle in the browser is the hard path; the token the page actually wants can be obtained out of band.

The solution is to read the challenge's sitekey off the page (the public widget id sitting in its data-sitekey attribute) and hand the work to a solver service instead of solving the puzzle in the browser. We'll build a small script that launches Puppeteer and loads the gated page, reads the sitekey from whichever widget is present, sends it and the page URL to a solver service so the service returns a signed token, then writes that token into the page's hidden response field and submits the form. It comes to about 80 lines of Node.js with Puppeteer and the native fetch, calling the solver's HTTP API directly so there is no SDK to pin.

The complete script

js
// solve-challenge.mjs
import puppeteer from 'puppeteer'

/* solver credentials and target come from the environment, never hardcoded.
   SOLVER_KEY is your 2Captcha (or compatible) API key. */
const SOLVER_KEY = process.env.SOLVER_KEY
const TARGET_URL = process.env.TARGET_URL
const API = 'https://api.2captcha.com'

/* submit one task and poll getTaskResult until it is ready. the service answers
   "processing" while it works, so this retries up to ~40 times at 5s apart. */
const solve = async (task) => {
  const created = await fetch(`${API}/createTask`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ clientKey: SOLVER_KEY, task })
  }).then(r => r.json())

  if (created.errorId !== 0) throw new Error(`createTask: ${created.errorDescription}`)
  const taskId = created.taskId

  for (let attempt = 0; attempt < 40; attempt++) {
    await new Promise(r => setTimeout(r, 5000))
    const result = await fetch(`${API}/getTaskResult`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ clientKey: SOLVER_KEY, taskId })
    }).then(r => r.json())

    if (result.errorId !== 0) throw new Error(`getTaskResult: ${result.errorDescription}`)
    if (result.status === 'ready') return result.solution
  }
  throw new Error('solver timed out')
}

const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' })

/* read the sitekey from whichever widget is present. Turnstile uses the
   .cf-turnstile container; reCAPTCHA v2 uses .g-recaptcha. both expose it as
   data-sitekey. the widget type decides the solver task type and the field name. */
const challenge = await page.evaluate(() => {
  const turnstile = document.querySelector('.cf-turnstile')
  if (turnstile) return { kind: 'turnstile', sitekey: turnstile.dataset.sitekey }
  const recaptcha = document.querySelector('.g-recaptcha')
  if (recaptcha) return { kind: 'recaptcha', sitekey: recaptcha.dataset.sitekey }
  return null
})

if (!challenge) throw new Error('no Turnstile or reCAPTCHA widget found on page')

/* proxyless task: the solver fetches the token over its own network. the two
   widget kinds map to two task types and two response field names. */
const taskMap = {
  turnstile: { type: 'TurnstileTaskProxyless', field: 'cf-turnstile-response' },
  recaptcha: { type: 'RecaptchaV2TaskProxyless', field: 'g-recaptcha-response' }
}
const { type, field } = taskMap[challenge.kind]

const solution = await solve({
  type,
  websiteURL: TARGET_URL,
  websiteKey: challenge.sitekey
})

/* Turnstile returns solution.token; reCAPTCHA returns solution.gRecaptchaResponse. */
const token = solution.token ?? solution.gRecaptchaResponse

/* write the token into the hidden field the page submits, then fire any
   widget callback so a page that waits on the callback proceeds too. */
await page.evaluate((field, token) => {
  let input = document.querySelector(`[name="${field}"]`)
  if (!input) {
    input = document.createElement('textarea')
    input.name = field
    input.style.display = 'none'
    document.forms[0]?.appendChild(input)
  }
  input.value = token
}, field, token)

await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(() => {}),
  page.evaluate(() => document.forms[0]?.submit())
])

console.log('Submitted with token:', token.slice(0, 24) + '...')
await browser.close()
bash
npm install puppeteer
SOLVER_KEY=your_key TARGET_URL=https://example.com/login node solve-challenge.mjs

How it works

Read credentials and target from the environment. SOLVER_KEY and TARGET_URL come from process.env, so the API key never lands in the file or the shell history of anyone reading the page. The script throws on a missing widget rather than submitting an empty token.

Launch headless and load the page. puppeteer.launch({ headless: true }) starts Chrome with no window, and waitUntil: 'networkidle2' holds until the challenge script has had time to inject its widget. Reading the sitekey before the widget mounts returns null, so on pages that inject the widget from a later script, wait for it first with await page.waitForSelector('.cf-turnstile, .g-recaptcha', { timeout: 15000 }) before the evaluate.

Find the sitekey and pick the task type. The page.evaluate block checks for the Turnstile container first, then the reCAPTCHA one, and returns the data-sitekey from whichever is present. The widget kind selects the solver type and the response field name through a lookup table, so adding a third widget is one more map entry rather than a branch. This flow targets the v2 checkbox and Turnstile; a reCAPTCHA v3 page returns a score rather than a checkbox token, so it needs the RecaptchaV3TaskProxyless task with the page's action name instead, and low scores still get blocked.

Run createTask, then poll getTaskResult. createTask registers the sitekey and URL and returns a taskId. The service answers status: 'processing' while it works, so the loop waits five seconds between reads and gives up after forty attempts. A non-zero errorId surfaces the service's own error string instead of failing silently. The proxyless task solves against the solver's network while your browser submits from your IP; when that reputation mismatch makes the site re-challenge or reject the token, switch to the proxied task type (TurnstileTask, RecaptchaV2Task) and pass the same residential proxy your browser uses, so the solve and the submission share a network path.

Inject the token and submit. The token goes into the hidden response field by name, creating the field if the page has not rendered it yet, then document.forms[0].submit() posts it. The ?? solution.gRecaptchaResponse covers the two property names the solver returns, token for Turnstile and gRecaptchaResponse for reCAPTCHA. A Turnstile or reCAPTCHA token is valid for only about two minutes, so submit right after injection and request the token last if the page does other work first. Two pitfalls bite here: some widgets carry a data-callback and only enable submit when that function fires, so read the name off the data-callback attribute and call window[name](token) after setting the field; and document.forms[0] grabs the first form on the page, so on a page with a search box or newsletter form, target the right one explicitly with something like document.querySelector('form#login').

Use this when

You are authorized to automate the target (your own property, a client engagement with permission, or a site whose terms allow it) and a Turnstile or reCAPTCHA v2 gate stands between your script and a request you are entitled to make. Confirm first that the site's terms permit automated access; a site that uses these challenges specifically to forbid automation is one where solving the challenge may breach those terms, and respect its robots.txt and rate limits regardless.

Skip this when

The data has an official API or export, in which case use that and skip the browser entirely; the challenge is reCAPTCHA v3 or Enterprise, in which case switch to the matching scored task type rather than this v2 flow; the gate is a one-off login you can perform by hand, in which case capture the session cookie once and reuse it; or the target's terms prohibit automated access, in which case do not scrape it at all.

Skip the code, just get the data

Simplescraper turns any website into structured data in seconds.