turnstile · cf-turnstile-response

cf-turnstile-response invalid: why the token is missing or rejected

Three different failures wear the same name. This page sorts them apart in one step, then points at the one you have — because the fix for a token that never arrived has nothing in common with the fix for one Cloudflare refused.

Where cf-turnstile-response comes from

Turnstile creates a hidden <input> holding the verification token. Two widget parameters govern it: response-field, "a boolean that controls if an input element with the response token is created, defaults to true", and response-field-name, the "name of the input element, defaults to cf-turnstile-response". Set either one and the field you are reading server-side changes or disappears.

The input is created alongside the widget. Browsers only submit form controls that are descendants of the <form> being submitted, so a widget rendered outside the form contributes nothing to the request body. This is the single most common reason a handler sees an empty string.

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

<form id="signup" method="POST" action="/signup">
  <input type="email" name="email" required>

  <!-- inside the form, or the hidden input is never submitted -->
  <div class="cf-turnstile"
       data-sitekey="YOUR_SITEKEY"
       data-callback="onTurnstileToken"
       data-expired-callback="onTurnstileExpired"></div>

  <button id="submit" type="submit" disabled>Sign up</button>
</form>

<script>
  function onTurnstileToken(token) {
    document.getElementById('submit').disabled = false;
  }
  function onTurnstileExpired() {
    document.getElementById('submit').disabled = true;
  }
</script>

Keeping the submit button disabled until data-callback fires removes the second common failure: the visitor submits while the challenge is still running, so the field exists but is empty. Cloudflare's api.js must be loaded from that exact URL — the docs state that "proxying or caching this file will cause Turnstile to fail when future updates are released".

Which of the three is yours

Read the value of the hidden field before anything else. It decides which branch you are on, and the branches share no fixes.

What you observeWhat it isGo to
Your server says the field is required, or missingThe token never reached you. Nothing was verified because there was nothing to verify.The cf-turnstile-response field is required
siteverify returns invalid-input-responseThe token arrived and Cloudflare would not honour it — expired, mangled, or never real.invalid-input-response
siteverify returns invalid-input-secretNot about the token at all. Your secret key is wrong.invalid-input-secret
siteverify returns timeout-or-duplicateThe token was fine and has already been spent.timeout-or-duplicate
Your app says "token rejected" and you cannot see a codeAn application's own wording around one of the above. Find the real code first."Turnstile token rejected"
→

If you cannot see a siteverify response at all, log its error-codes array. Everything below is the long form of the table above, and the array skips straight to the answer.

Read the siteverify error code before anything else

Every diagnosis starts with what Cloudflare actually returned. The siteverify response always comes back as JSON with success, and on failure an error-codes array.

error-codes valueCloudflare's meaningWhere the fault sits
missing-input-responseThe response parameter was not providedClient or transport: the token never made it into your POST body
invalid-input-responseThe token is invalid, malformed or expiredThe token itself: truncated, older than 300 seconds, or a dummy token sent to a production secret
missing-input-secretThe secret parameter was not providedYour server call: an unset environment variable, usually
invalid-input-secretThe secret key is invalid or expiredKey configuration: the sitekey pasted where the secret belongs, or a rotated key
timeout-or-duplicateThe token has already been validatedYour flow: siteverify called twice with the same token
bad-requestThe request is malformedEncoding or content type of your POST
internal-errorInternal error occurredCloudflare side; retry with an idempotency_key
→

The key is error-codes, hyphenated. Integrations that read error_codes or errorCodes log undefined and send developers hunting in the wrong place.

The token never reached your server

If you get missing-input-response, or your own guard clause fires before you ever call siteverify, work through these:

  • The widget <div> sits outside the <form>. Move it inside, or set data-response-field="false" and write the token into your own input from data-callback.
  • The form is submitted over fetch or XHR with a hand-built JSON body. FormData(form) picks the hidden input up automatically; a hand-built body does not. Read it explicitly with turnstile.getResponse(widgetId).
  • data-response-field-name is set, so the field arrives under a different name than the one your handler reads.
  • The widget uses execution: "execute", which obtains the token on demand rather than on render. No turnstile.execute() call means no token.
  • The widget failed to render at all. Check the browser console for a client-side error code — 110100 and 400020 are "invalid sitekey", 110110 is "sitekey not found", 400070 is "sitekey disabled", 110200 is "domain not authorized".
  • Your framework strips unknown fields from the request body before your handler runs, or a proxy drops them.
js
// Explicit render + fetch submit: read the token yourself.
let widgetId;

window.onTurnstileLoad = () => {
  widgetId = turnstile.render('#turnstile-container', {
    sitekey: 'YOUR_SITEKEY',
    'refresh-expired': 'auto',
  });
};

document.getElementById('signup').addEventListener('submit', async (event) => {
  event.preventDefault();

  // getResponse() returns undefined if the challenge has not finished.
  const token = turnstile.getResponse(widgetId);
  if (!token || turnstile.isExpired(widgetId)) {
    turnstile.reset(widgetId);
    return;
  }

  await fetch('/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: event.target.email.value,
      'cf-turnstile-response': token,
    }),
  });
});

Load api.js with ?render=explicit&onload=onTurnstileLoad when you render this way.

The token arrived and Cloudflare rejected it

Tokens expire after 300 seconds and each token can only be validated once. Most invalid-input-response and timeout-or-duplicate failures reduce to one of those two rules, or to a key pairing that never matched.

What you seeCauseFix
invalid-input-response in local dev onlyA dummy sitekey token sent to a production secret key. Cloudflare is explicit: "Production secret keys will reject the dummy token."Pair dummy with dummy — sitekey 1x00000000000000000000AA with secret 1x0000000000000000000000000000000AA
invalid-input-response on long formsThe token is older than 300 seconds by the time the user submitsLeave refresh-expired at its auto default, wire expired-callback to disable submit, and re-read the token at submit time
timeout-or-duplicate on double-clicked submitsThe same token was verified twice; the first call consumes itDisable the button on first submit; send an idempotency_key so genuine retries return the same outcome
timeout-or-duplicate behind a retrying job queueA retried request re-verifies a spent tokenVerify once at the edge of the request and persist the result, or use idempotency_key
invalid-input-secret on every requestThe sitekey was pasted into the secret parameter, or the secret was rotated in the dashboardThe secret is the longer of the two keys and must never appear in HTML; rotate your deployed value to match
success: true but from the wrong siteA token minted by a different property against a shared secretCompare the returned hostname (and action, if you set one) to what you expect

Keys and hostnames: the two configuration mismatches

Turnstile gives each widget a sitekey and a secret key. The sitekey is public and goes in data-sitekey. The secret is server-only and goes in the secret parameter of siteverify. Swapping them fails in two distinct ways rather than one: the secret used as a sitekey produces a widget that never renders (client error 110100 or 110110, so the field stays empty and you get missing-input-response), while the sitekey used as a secret produces invalid-input-secret. The error code tells you which direction the swap went.

Hostname mismatch is separate. A widget is bound to the hostnames configured under Hostname Management, and "when you add a hostname, the widget will work on that exact hostname and all of its subdomains". Free plans allow up to 10 hostnames per widget, Enterprise up to 200. Load the widget from an unlisted hostname and the browser reports 110200, "domain not authorized" — again no token, again missing-input-response server-side. Staging domains, preview deployments and localhost each need to be listed on whichever sitekey that environment uses.

Note what siteverify does not do: it does not reject a token because the hostname is not one of yours. It returns the hostname the challenge was served on and leaves the comparison to you. If you run multiple properties on one secret, that check is yours to write.

The siteverify request shape

One POST to https://challenges.cloudflare.com/turnstile/v0/siteverify. It "accepts both application/x-www-form-urlencoded and application/json requests, but always returns JSON responses". Required parameters are secret and response; remoteip and idempotency_key are optional.

bash
curl -sS -X POST https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d "secret=$TURNSTILE_SECRET_KEY" \
  --data-urlencode "response=$CF_TURNSTILE_RESPONSE" \
  -d "remoteip=203.0.113.7"

# Success:
# {"success":true,"challenge_ts":"2026-08-13T09:14:22.117Z","hostname":"example.com","action":"signup","cdata":""}
#
# Failure:
# {"success":false,"error-codes":["invalid-input-response"]}

Use --data-urlencode for the token. Tokens are long and contain characters that a bare -d will mangle, which is a quiet way to manufacture your own invalid-input-response.

js
import crypto from 'node:crypto';

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

export async function verifyTurnstile(req) {
  const token = req.body['cf-turnstile-response'];
  if (!token) {
    return { ok: false, reason: 'missing-input-response' };
  }

  const body = new URLSearchParams({
    secret: process.env.TURNSTILE_SECRET_KEY,
    response: token,
    remoteip: req.headers['cf-connecting-ip'] ?? req.ip,
    idempotency_key: crypto.randomUUID(),
  });

  const res = await fetch(SITEVERIFY, { method: 'POST', body });
  const outcome = await res.json();

  if (!outcome.success) {
    // Log the array, not a boolean. This is your only diagnostic.
    return { ok: false, reason: outcome['error-codes']?.join(',') };
  }

  // siteverify will not do these two checks for you.
  if (outcome.hostname !== 'example.com') {
    return { ok: false, reason: 'hostname-mismatch' };
  }
  if (outcome.action !== 'signup') {
    return { ok: false, reason: 'action-mismatch' };
  }

  return { ok: true, outcome };
}

Generate the idempotency_key once per logical verification and reuse it across network retries. A fresh UUID on every retry defeats the purpose and lands you back on timeout-or-duplicate.

A checklist that ends the guessing

  • Open the network tab and inspect the actual request body. Is cf-turnstile-response present, and is it non-empty? That one observation splits the problem in half.
  • If absent: is the widget inside the <form>, did the widget render without a console error code, and does your body serialisation carry unknown fields?
  • If present but rejected: log the full error-codes array. Do not log success alone.
  • Confirm the sitekey in your HTML and the secret on your server belong to the same widget, and that dummy keys are paired with dummy keys.
  • Time the gap between challenge completion and siteverify. Anything approaching 300 seconds needs a token refresh before submit.
  • Verify each token exactly once, and disable the submit control after the first attempt.

For automated testing the widget is the obstacle rather than the bug. On properties you own or are authorised to test — QA suites, CI, uptime checks — SolveGate returns a Turnstile token from one REST call (POST /v1/solve with gate, sitekey and url), which you then post as cf-turnstile-response and validate through your normal siteverify path. That keeps the server-side half of your flow under test instead of stubbed out.

Common questions

It is the name of the hidden input Turnstile creates to carry the verification token to your server. The name is the default value of the response-field-name widget parameter, and creation of the input is controlled by response-field, which defaults to true. Your server reads that field and sends its value as the response parameter to Cloudflare's siteverify endpoint.

Most often the widget is rendered outside the <form> being submitted, so the browser never includes the hidden input in the request body. The other frequent causes are submitting before the challenge finishes, serialising the form into a hand-built JSON body that omits the field, and a widget that failed to render at all — check the console for codes like 110100 (invalid sitekey) or 110200 (domain not authorized).

Tokens expire after 300 seconds, or five minutes. The visitor must complete the challenge and have the token reach siteverify inside that window. On long forms, leave refresh-expired at its auto default and re-read the token at submit time rather than capturing it once on page load.

No. Each token can only be validated once, and the first siteverify call consumes it. A second call with the same token returns success: false with the error code timeout-or-duplicate. If you need to retry a verification safely across network failures, send the same idempotency_key UUID on each attempt.

invalid-input-response is a problem with the token: malformed, expired, truncated in transit, or a dummy token sent to a production secret key. invalid-input-secret is a problem with your key: the secret is wrong, has been rotated, or is actually the sitekey pasted into the secret parameter. The first is a client or timing issue, the second is a configuration issue.

Not on your behalf. Hostname restrictions are enforced at the widget, where an unlisted domain produces client-side error 110200. Siteverify returns the hostname the challenge was served on and expects you to compare it to your own domain, along with action if you set one. Write those checks explicitly if you use a single secret across several properties.

Related

Sources

More in Turnstile errors

Turnstile error 300030Error 300030 means a Turnstile widget mounted and then stopped responding. The real causes, the codes it gets confused with, and the fixes.Turnstile timeout-or-duplicate errorsiteverify returns timeout-or-duplicate when a Turnstile token is verified twice or is older than 300 seconds. The causes, and how to fix each one.Turnstile callback not firingWhy the Turnstile success callback never runs: implicit rendering, callbacks not on window, hidden containers and SPA remounts.Cloudflare Turnstile error 600010Turnstile error 600010 is a generic challenge failure in the 600* family. What Cloudflare documents, the reproduced causes, and how to fix it.Turnstile stuck on "Verifying you are human"Why Turnstile hangs on "Verifying you are human": how to read the error code, plus fixes for CSP, clock skew, blocked domains and headless CI.invalid-input-responsesiteverify returns invalid-input-response when a token is invalid, malformed or expired. The causes worth checking, in the order they occur.invalid-input-secretsiteverify returns invalid-input-secret when your secret key is rejected. Why the key is usually right, and the five things that actually cause it.Token rejected"Token rejected" is not a Cloudflare error — it is what an app prints when siteverify refused its token. What to do, as a visitor or as a developer.Field is requiredA Laravel validation message meaning the Turnstile token never reached your server. Why the field is empty, how to tell which cause it is, and the fix for each.Turnstile 401 in consoleA 401 on a /cdn-cgi/challenge-platform/ pat/ request is expected. Cloudflare documents it: the browser could not get a Private Access Token, and falls back.Turnstile verification loopThe widget completes, then challenges again. Cloudflare calls it a challenge loop. The five documented causes, how to tell them apart, and what each one fixes.Turnstile script fails to loadapi.js never runs, or turnstile is undefined when you call render. The four causes worth checking first, starting with the one nobody expects: Rocket Loader.

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