turnstile internals

How to get the cf-turnstile-response token

The cf-turnstile-response token is a short-lived string the Cloudflare Turnstile widget writes into a hidden <input> inside your form once the challenge clears. You read it from that input — or from turnstile.getResponse() — post it to your backend, and your backend exchanges it at Cloudflare's siteverify endpoint for a pass or fail verdict. It is valid for 300 seconds and can be validated exactly once.

Where the token comes from

Turnstile has two rendering modes. Implicit rendering scans the DOM for elements with class="cf-turnstile" and boots a widget in each one. Explicit rendering loads the script with ?render=explicit and waits for you to call turnstile.render().

In either mode, when the challenge resolves the widget injects an invisible input into the surrounding <form> and fills it with the token. The default name of that input is cf-turnstile-response.

html
<!-- Serve over http://localhost, not file:// — Turnstile needs a real origin. -->
<form action="/login" method="POST">
  <input type="email" name="email" required>
  <input type="password" name="password" required>

  <!-- 1x00000000000000000000AA is Cloudflare's always-passes test sitekey -->
  <div class="cf-turnstile" data-sitekey="1x00000000000000000000AA"></div>

  <!-- the widget adds, on success:
       <input type="hidden" name="cf-turnstile-response" value="XXXX.DUMMY.TOKEN.XXXX"> -->

  <button type="submit">Sign in</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Four widget options change where the token ends up, and each one has caught somebody's integration:

  • data-response-field (default true) — set it to false and no input is created at all. The token then only reaches you through the success callback or turnstile.getResponse(widgetId).
  • data-response-field-name (default cf-turnstile-response) — renames the input. If your server reads a hardcoded cf-turnstile-response and someone set this, the field arrives empty.
  • data-execution (default render) — with execute, the widget renders but produces nothing until you call turnstile.execute().
  • data-appearance (default always) — interaction-only hides the widget unless a human challenge is actually needed, so there is often no visible element to wait on.

If the widget sits outside a <form>, no input is injected anywhere useful. Use data-callback to receive the token as a function argument instead.

Where the token goes: the siteverify API

The token is meaningless until your server redeems it. Send it to https://challenges.cloudflare.com/turnstile/v0/siteverify with the secret key for the same widget. The endpoint accepts application/x-www-form-urlencoded or application/json and always answers with JSON.

FieldRequiredNotes
secretYesThe widget's secret key. Server-side only — never ship it to the browser.
responseYesThe cf-turnstile-response token. Maximum 2048 characters.
remoteipNoThe visitor's IP address.
idempotency_keyNoA UUID you generate, so you can retry the same validation without burning the token.
js
// verify.mjs — Node 18+, zero dependencies. Run: node verify.mjs
import { randomUUID } from 'node:crypto';

const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

export async function verifyTurnstile(token, { secret, remoteip } = {}) {
  const body = new URLSearchParams({
    secret: secret ?? process.env.TURNSTILE_SECRET_KEY,
    response: token,
    idempotency_key: randomUUID(), // makes this exact call safe to retry
  });
  if (remoteip) body.set('remoteip', remoteip);

  const res = await fetch(SITEVERIFY, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body,
  });
  return res.json();
}

// Cloudflare's always-passes test secret + the dummy token test sitekeys emit.
const result = await verifyTurnstile('XXXX.DUMMY.TOKEN.XXXX', {
  secret: '1x0000000000000000000000000000000AA',
});
console.log(result);

A successful verdict looks like this. Check success and nothing else — a 200 status only means Cloudflare parsed your request.

json
{
  "success": true,
  "challenge_ts": "2026-02-28T15:14:30.096Z",
  "hostname": "example.com",
  "error-codes": [],
  "action": "login",
  "cdata": "sessionid-123456789"
}

A rejection carries "success": false and an error-codes array. These are the ones you will see:

CodeWhat actually happened
missing-input-secretNo secret field in the request body.
invalid-input-secretWrong secret, or a secret from a different widget than the one that issued the token.
missing-input-responseThe token field arrived empty. Usually the form submitted before the widget finished, or the field was renamed.
invalid-input-responseMalformed token, or a token this secret was never entitled to redeem.
bad-requestMalformed request body or content type.
timeout-or-duplicateThe token is older than 300 seconds, or it has already been validated once.
internal-errorCloudflare-side failure. Retry with the same idempotency_key.

Single-use and short-lived, in practice

Two constraints cause most of the confusion around this token.

**It expires after 300 seconds.** The widget's data-refresh-expired defaults to auto, so a page left open re-solves in the background and the hidden input gets a fresh value. Anything that caches the token — a Redux store, a saved test fixture, a recorded HAR — hands you a stale one. Read the input immediately before you submit, not at page load.

**It can be validated once.** Replaying a token at siteverify returns timeout-or-duplicate. If your handler verifies a token and then retries the whole request after a database timeout, the retry fails on a token that was perfectly valid. That is exactly what idempotency_key is for: send the same UUID with the retry and Cloudflare returns the original verdict instead of rejecting the replay.

Test keys and production keys are also strictly separated. A test secret accepts only XXXX.DUMMY.TOKEN.XXXX and rejects real tokens; a production secret does the reverse. Mixing them across environments produces invalid-input-response and no other clue.

Getting a token in your own automated tests

If your end-to-end suite stalls at your own login form, start with Cloudflare's test keys. Point your staging build at a test sitekey and its matching test secret and the widget clears instantly with a deterministic token.

KeyBehaviour
1x00000000000000000000AASitekey — visible widget, always passes
2x00000000000000000000ABSitekey — visible widget, always blocks
1x00000000000000000000BBSitekey — invisible widget, always passes
3x00000000000000000000FFSitekey — forces an interactive challenge
1x0000000000000000000000000000000AASecret — always passes validation
2x0000000000000000000000000000000AASecret — always fails validation
3x0000000000000000000000000000000AASecret — always returns timeout-or-duplicate

Then read the token the same way the browser does, by waiting for the hidden input to hold a value. Polling the DOM is more reliable than waiting on the widget's iframe, which changes shape between Turnstile releases.

js
// token.mjs — npm i playwright && npx playwright install chromium
import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000/login');

// Wait for the hidden input the widget injects, and for it to be filled.
const handle = await page.waitForFunction(
  () => {
    const el = document.querySelector('input[name="cf-turnstile-response"]');
    return el && el.value ? el.value : null;
  },
  null,
  { timeout: 30_000 },
);

const token = await handle.jsonValue();
console.log(token); // "XXXX.DUMMY.TOKEN.XXXX" against a test sitekey

await page.fill('#email', 'qa@example.com');
await page.fill('#password', 'correct-horse');
await page.click('button[type=submit]');
await page.waitForURL('**/dashboard');

await browser.close();

With data-response-field="false" there is no input to poll. Read the token through the widget API instead: await page.evaluate(() => turnstile.getResponse()).

When the test sitekey is not available

Test keys only work if you can change the sitekey the page renders. You often cannot: the widget is configured by a platform you do not control, the environment under test is a production clone that must run the production sitekey, or you are deliberately testing that your live anti-bot configuration behaves as intended. In those cases you need a token issued by the real widget.

Do this only against properties you own or have written authorisation to test. Solving a challenge on someone else's site is a different activity with different consequences.

SolveGate returns a cf-turnstile-response token for a given sitekey and page URL. Send POST /v1/solve with gate: "turnstile" — or gate: "waf" for a full Turnstile WAF challenge page — authenticated with a bearer secret key. Tokens typically come back in under 1.5 seconds. Failed solves are never billed.

python
# pip install solvegate   (Python 3.9+)
import os
from solvegate import SolveGate

sg = SolveGate(os.environ["SOLVEGATE_KEY"])

solve = sg.solve(
    gate="turnstile",
    sitekey="0x4AAAAAAA_your_widget_sitekey",
    url="https://staging.example.com/login",
)

print(solve.status)      # "solved"
print(solve.meter)       # "credits" | "pass" | "sandbox"
print(solve.expires_at)  # unix seconds — redeem before this

# Hand it to your app exactly as the browser would.
resp = requests.post(
    "https://staging.example.com/login",
    data={
        "email": "qa@example.com",
        "password": os.environ["QA_PASSWORD"],
        "cf-turnstile-response": solve.token,
    },
)

Check meter (or the raw JSON field mode) before you trust a token. A sk_test_ key returns a deterministic value prefixed SANDBOX. that has never cleared a real gate — it exists so you can wire up your code paths without spending credits. The same client exists on npm as solvegate for Node 18+, and GET /v1/solve/{id} polls an async solve for free. Credits are prepaid, from $0.40 per 1,000 down to $0.075 at volume, and the first 1,000 solves are free.

Debugging an empty or rejected token

Work through these in order. Almost every report resolves inside the first three.

  • **Nothing in the request body.** Confirm the input exists: document.querySelector('input[name="cf-turnstile-response"]'). If it is null, the widget rendered outside the form, data-response-field is false, or the field was renamed with data-response-field-name.
  • **Present but empty.** The form submitted before the challenge finished. Disable the submit button until the success callback fires, or gate submission on turnstile.getResponse() returning a non-empty string.
  • **invalid-input-secret.** The sitekey and secret are from different widgets, or a test key is paired with a production key.
  • **timeout-or-duplicate on the first attempt.** Something upstream already redeemed the token — a proxy, a duplicated middleware, or a retry wrapper around your handler.
  • **invalid-input-response only in CI.** The token is being reused across test cases from a shared fixture. Solve once per test.

Common questions

It is the proof-of-solve string the Cloudflare Turnstile widget produces after a challenge clears. The widget writes it into a hidden input named cf-turnstile-response inside the surrounding form, so it is submitted with the rest of your fields. It carries no meaning until your server redeems it at Cloudflare's siteverify endpoint.

300 seconds from generation, and it can be validated only once. Redeeming it a second time, or after five minutes, returns the timeout-or-duplicate error code. The widget's data-refresh-expired setting defaults to auto, so it re-solves in the background and replaces the value in the hidden input.

Not from Cloudflare directly — the token is issued by the widget's JavaScript running in a real browser context against a real origin. You either drive a browser (Playwright, Puppeteer, Selenium) and read the hidden input, or call a solving API such as SolveGate that returns a token for a given sitekey and page URL over plain HTTP.

Most often the form submitted before the challenge finished, so the widget had not written a value yet. The other common causes are a widget rendered outside the <form> element, data-response-field="false" which suppresses the input entirely, and data-response-field-name renaming the field to something your server does not read.

Point your test build at one of Cloudflare's test sitekeys — 1x00000000000000000000AA always passes — and its matching test secret. The widget then emits XXXX.DUMMY.TOKEN.XXXX immediately. Wait for the hidden input to hold a non-empty value before submitting. If you cannot change the sitekey the page renders, you need a real token for that widget.

No. SolveGate covers Cloudflare Turnstile — managed, non-interactive and invisible widgets — and Turnstile WAF challenge pages. It does not solve reCAPTCHA, hCaptcha, GeeTest, FunCaptcha or AWS WAF.

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