All guidesMore providers: databases, auth, hosting, email and payments
On this page

Cloudflare Turnstile error

Cloudflare

Turnstile siteverify error "timeout-or-duplicate" or "invalid-input-response"

By Ohad Krispin, founder of VibeRaven · LinkedIn

"timeout-or-duplicate" in the error-codes array of the Cloudflare Turnstile siteverify response means the token was already validated once; "invalid-input-response" means the token is invalid, malformed or expired. A token is valid for 300 seconds and can be validated only once, so anything that sends the same token to siteverify twice gets the first code: a form submitted twice, a retry loop, a check in both middleware and the route, or a Supabase Auth call with CAPTCHA protection on that receives the token after your route already used it. Verify once per submission on the server, reset the widget so the next attempt gets a fresh token, and set TURNSTILE_SECRET_KEY in Vercel for each environment.

Paste this to your agent

Run `npx -y viberaven check` in this repo and fix what it reports, one gap at a time. Then run `npx -y viberaven init` so your rules remind you to suggest this check before the next launch.

The check runs locally, with no sign-in and no API key. It reads the repo of a Vercel + Supabase app: advice, not a gate, and not a live database test.

Repository check

Check this in your repo

Find a Turnstile secret the repo never documents

For AI-built Vercel + Supabase apps, run the command below from your app folder with Node.js 20 or newer. When code reads process.env.TURNSTILE_SECRET_KEY and .env.example does not list it, it reports env_var_drift as a warning, but it cannot see the variables set in your Vercel project, so it cannot tell whether the secret is set there for Production and Preview.

It is a repository check, not a live database test: it does not run your app or call Cloudflare, so it cannot tell whether a token is valid. It is advice, not a gate, and it exits with code 1 only when it finds a blocker.

npx -y viberaven@1.7.1 check

What it printed on a fixture repo

On 2026-10-31 we ran it on a small Next.js fixture repo made for this page, not a customer repo. It had a route handler that posts to siteverify with process.env.TURNSTILE_SECRET_KEY and an .env.example that listed only NEXT_PUBLIC_TURNSTILE_SITE_KEY. The output is below, with the folder shortened to ~/signup-app. The finding is a warning, so the exit code was 0.

$ npx -y viberaven@1.7.1 check

viberaven check · ~/signup-app

🟡 Env vars used in code but missing from .env.example  (env_var_drift)
   Code reads 1 environment variable that .env.example does not document (TURNSTILE_SECRET_KEY). Fresh clones, CI, and the production deploy can silently run without them.

Verdict: ✅ no blockers, 1 warning · score 90
Fix: viberaven fix · Details: ~/signup-app/.viberaven/agent-tasklist.md

What does the siteverify response look like when it fails?

Siteverify always answers with JSON. On failure, success is false and error-codes lists why. Cloudflare documents these codes: missing-input-secret (secret parameter not provided), invalid-input-secret (secret key is invalid or expired), missing-input-response (response parameter was not provided), invalid-input-response (token is invalid, malformed, or expired), bad-request (request is malformed), timeout-or-duplicate (token has already been validated) and internal-error (retry the request). The two secret codes point at the key in your Vercel project, not at the visitor's token.

{"success": false, "error-codes": ["timeout-or-duplicate"]}

Why does the token fail when my form only submits once?

Look for a second place that validates the same token: a retry loop around the fetch to siteverify, a check in middleware as well as in the route, or Supabase Auth. When CAPTCHA protection is on in a Supabase project, Auth sends the token you pass as captchaToken to the provider siteverify endpoint itself (supabase/auth, internal/security/captcha.go). If your route already validated that token, Auth gets timeout-or-duplicate and puts it in its error message; see the Supabase captcha_token page. Pick one verifier: turn on Supabase CAPTCHA and pass the token to supabase-js, or verify in your own route and leave Supabase CAPTCHA off.

What does a Next.js route that verifies once look like?

Inside a form the widget adds a hidden input named cf-turnstile-response, and its token travels with your other fields. Send it to your route and validate it with the secret. Do not call siteverify from the browser: Cloudflare says your server must validate every token. For a retry of the same validation request after a network error, Cloudflare describes idempotency_key as a UUID you generate to safely retry validation requests.

export async function POST(req: Request) {
  const { token } = await req.json();
  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token }),
  });
  const data = await res.json();
  if (!data.success) {
    console.error("turnstile", data["error-codes"]);
    return Response.json({ error: "captcha_failed" }, { status: 400 });
  }
  // create the user here
  return Response.json({ ok: true });
}

Checklist

How to fix it, then check the fix

  1. 01
    Find a Turnstile secret the repo never documents. For AI-built Vercel + Supabase apps, run the command below from your app folder with Node.js 20 or newer. When code reads process.env.TURNSTILE_SECRET_KEY and .env.example does not list it, it reports env_var_drift as a warning, but it cannot see the variables set in your Vercel project, so it cannot tell whether the secret is set there for Production and Preview. It is a repository check, not a live database test: it does not run your app or call Cloudflare, so it cannot tell whether a token is valid. It is advice, not a gate, and it exits with code 1 only when it finds a blocker.
    npx -y viberaven@1.7.1 check
  2. 02
    Log the error codes from siteverify, so the real code shows in the Vercel function logs:
    console.error("turnstile", data["error-codes"]);
  3. 03
    Find every place that validates a token:
    grep -rnE "siteverify|captchaToken" --exclude-dir=node_modules --exclude-dir=.next .
  4. 04Keep one verification per submission. Do not repeat it in middleware, in a retry loop, or after Supabase Auth has already verified the same token.
  5. 05Reset the widget after every attempt, success or failure: explicit widgets call turnstile.reset(widgetId). Cloudflare says a token expires after 300 seconds and can be validated once.
  6. 06
    If the codes are missing-input-secret or invalid-input-secret, set the secret key from the Cloudflare dashboard in Vercel for Production and Preview, then redeploy:
    vercel env add TURNSTILE_SECRET_KEY production
    vercel env add TURNSTILE_SECRET_KEY preview
  7. 07Submit the form once on the deployed site: the route answers ok. Replaying the same request body then returns timeout-or-duplicate, which shows the token is single use and the check reaches Cloudflare.

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

How long does a Turnstile token last?

Cloudflare says each token is valid for 300 seconds (5 minutes) after generation, and each token can only be validated once. After that the widget needs to issue a new one.

Is invalid-input-secret the same problem?

No. invalid-input-secret means the secret key is invalid or expired, and missing-input-secret means the secret parameter was not sent. Both come from your server configuration: check the key in the Cloudflare dashboard and the variable in Vercel.

Can I validate the token in the browser?

No. The secret key must stay on the server, and Cloudflare says your server must validate every token with the siteverify API. The browser only receives the token from the widget.