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.
<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 value | Cloudflare's meaning | Where the fault sits |
|---|---|---|
missing-input-response | The response parameter was not provided | Client or transport: the token never made it into your POST body |
invalid-input-response | The token is invalid, malformed or expired | The token itself: truncated, older than 300 seconds, or a dummy token sent to a production secret |
missing-input-secret | The secret parameter was not provided | Your server call: an unset environment variable, usually |
invalid-input-secret | The secret key is invalid or expired | Key configuration: the sitekey pasted where the secret belongs, or a rotated key |
timeout-or-duplicate | The token has already been validated | Your flow: siteverify called twice with the same token |
bad-request | The request is malformed | Encoding or content type of your POST |
internal-error | Internal error occurred | Cloudflare 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 setdata-response-field="false"and write the token into your own input fromdata-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 withturnstile.getResponse(widgetId). data-response-field-nameis 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. Noturnstile.execute()call means no token. - The widget failed to render at all. Check the browser console for a client-side error code —
110100and400020are "invalid sitekey",110110is "sitekey not found",400070is "sitekey disabled",110200is "domain not authorized". - Your framework strips unknown fields from the request body before your handler runs, or a proxy drops them.
// 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 see | Cause | Fix |
|---|---|---|
invalid-input-response in local dev only | A 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 forms | The token is older than 300 seconds by the time the user submits | Leave 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 submits | The same token was verified twice; the first call consumes it | Disable the button on first submit; send an idempotency_key so genuine retries return the same outcome |
timeout-or-duplicate behind a retrying job queue | A retried request re-verifies a spent token | Verify once at the edge of the request and persist the result, or use idempotency_key |
invalid-input-secret on every request | The sitekey was pasted into the secret parameter, or the secret was rotated in the dashboard | The 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 site | A token minted by a different property against a shared secret | Compare 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.
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.
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-responsepresent, 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-codesarray. Do not logsuccessalone. - 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