turnstile · cf-turnstile-response

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

Either cf-turnstile-response is empty by the time it reaches your handler, or it is populated and Cloudflare's siteverify endpoint refuses it — and the two have entirely different causes. Read the error-codes array in the siteverify response first: missing-input-response means the token never arrived, invalid-input-response means it arrived but was malformed, expired or minted against a different key.

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".

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), typically in under 1.5 seconds, 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

More in Turnstile errors

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