turnstile · siteverify · timeout-or-duplicate

Turnstile timeout-or-duplicate error: single-use tokens, expired tokens, and the fixes

siteverify answers timeout-or-duplicate when the token you sent has already been validated once, or was generated more than 300 seconds ago. Turnstile tokens are single-use and expire after five minutes, so the fix is to verify each token exactly once on your server and call turnstile.reset() to mint a fresh one before any resubmit.

What timeout-or-duplicate means

Cloudflare's Siteverify API returns this code inside the error-codes array of a failed response. Cloudflare's own description of it is "Token has already been validated". Two documented rules produce it: a token has a validity period of 300 seconds (5 minutes) from generation, and each token can only be validated once. Break either rule and success comes back false.

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

A failed response carries nothing else. There is no challenge_ts, no hostname, no action, no cdata — those fields only appear when success is true. You cannot read the token's age out of the rejection itself.

→

The code does not tell you which of the two happened. Cloudflare returns the same string for a five-minute-old token and for a token you verified twice a second apart. Log the elapsed time between the widget callback and your siteverify call, and log every siteverify call keyed by token, before you start guessing.

Why your token was rejected: causes and fixes

In production the expiry case is the less common one. Most reports come down to the same token reaching siteverify twice, from code paths whose authors did not know they were both verifying.

CauseWhat you seeFix
Two verification paths on the server — middleware plus route handler, or an auth callback invoked twice by the frameworkThe first submit fails; your logs show two siteverify calls with the same token milliseconds apartVerify in one place. Attach the result to the request or session and read it downstream instead of re-verifying
The client resubmits with the same token after a failed requestThe first attempt fails for an unrelated reason, then every retry fails with this code until the widget refreshes itselfClear the stored token and call turnstile.reset() on every failure path, not only on success
A double-clicked submit button, or a component effect that fires twiceIntermittent failures, often visible only in developmentDisable the submit control on submit and clear the token as soon as you hand it off
Your siteverify HTTP call was retried after a timeout, by your code or by a proxySporadic failures that correlate with network trouble rather than user behaviourSend idempotency_key: one UUID generated per token, reused on every retry of that token
The token was generated more than 300 seconds before submissionLong forms, large file uploads, slow uploads, tabs left open in the backgroundRefresh the token before submit; keep refresh-expired at its auto default and handle expired-callback
The token was stored in a session, database or job queue and verified laterVerification fails whenever it runs off the request pathNever persist tokens. Verify inline, then store your own boolean
One token used for two endpoints, such as a validate step followed by a submit stepThe second call always fails, the first always succeedsOne token per siteverify call. Render a widget per step, or verify once and trust your own session flag
The test secret key 3x0000000000000000000000000000000AAEvery verification fails, in development only, with a real-looking tokenThat key exists to always return this code. Switch to 1x0000000000000000000000000000000AA

Fix the server: verify each token exactly once

Treat siteverify as a redemption, not a lookup. Once it answers, the token is spent — including when the answer never reaches you because the connection dropped. Retry the HTTP request if you must, but carry an idempotency_key so the retry is not read as a replay. Cloudflare documents the parameter as "A UUID you generate to safely retry validation requests": generate it once per token and reuse it across every attempt for that token.

js
import { randomUUID } from "node:crypto";

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

async function verifyTurnstile(token, remoteip) {
  // One key per token, reused across retries of that same token.
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch(SITEVERIFY, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          secret: process.env.TURNSTILE_SECRET_KEY,
          response: token,
          remoteip,
          idempotency_key: idempotencyKey,
        }),
      });
      return await res.json();
    } catch (err) {
      if (attempt === 2) return { success: false, "error-codes": ["internal-error"] };
      await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
    }
  }
}

export async function handleSignup(req, res) {
  const token = req.body["cf-turnstile-response"];
  if (!token) return res.status(400).json({ error: "missing_token" });

  const result = await verifyTurnstile(token, req.headers["cf-connecting-ip"]);

  if (!result.success) {
    const codes = result["error-codes"] ?? [];
    // The token is spent or stale either way: the client must get a new one.
    const needsFreshToken =
      codes.includes("timeout-or-duplicate") ||
      codes.includes("invalid-input-response");
    return res.status(400).json({ error: "captcha_failed", codes, needsFreshToken });
  }

  // Verified. Record your own flag and never call siteverify with this token again.
  req.session.humanVerifiedAt = Date.now();
  return createAccount(req, res);
}

Two details in that handler matter. The verification result is written to the session rather than re-derived later, so nothing downstream is tempted to verify again. And the failure response tells the client whether it needs a new token, which is what makes the client-side fix below possible.

Fix the client: reset the widget before any resubmit

The classic dead-end looks like this. The form submits, your server verifies the token and then rejects the request for an unrelated reason — a validation error, a duplicate email, a 500. The form re-enables its submit button while still holding the old token. Every subsequent attempt now fails with timeout-or-duplicate, and the "please try again" message can never succeed. This bug is common enough that it shows up verbatim in public issue trackers.

Call turnstile.reset(widgetId) on every path that ends without a completed submission. The widget solves again and fires your callback with a new token.

js
// api.js loaded with ?render=explicit&onload=onTurnstileLoad
const form = document.querySelector("#signup");
const submitBtn = form.querySelector("button[type=submit]");
let widgetId = null;
let token = null;

function setToken(value) {
  token = value;
  submitBtn.disabled = !value;
}

window.onTurnstileLoad = () => {
  widgetId = turnstile.render("#turnstile", {
    sitekey: "<YOUR-SITE-KEY>",
    // Fires on the first solve and again after every reset or auto-refresh.
    callback: setToken,
    "expired-callback": () => setToken(null),
    "error-callback": () => setToken(null),
  });
  setToken(null);
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (!token) return;

  const spent = token;
  setToken(null); // single-use: the token is gone the moment it leaves the page

  const res = await fetch("/signup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      email: form.email.value,
      "cf-turnstile-response": spent,
    }),
  });

  if (!res.ok) {
    turnstile.reset(widgetId); // new token before the visitor tries again
  }
});

Clearing the token before the request goes out kills the double-click case for free: the second click finds token null and returns early. Keep the submit control disabled until callback hands you a replacement, so nobody can submit a form that carries no token at all.

The 300-second window

The clock starts when the token is generated, not when the page loads and not when the form is submitted. Cloudflare states that a visitor must initiate the request and submit the token to your backend inside the five-minute window, and that otherwise the widget needs to be refreshed to generate a new token.

  • refresh-expired defaults to auto, so a rendered widget normally replaces its own token before it goes stale. The usual failure is application code that copied the token into state on the first callback and never updated it — the widget refreshed, your variable did not.
  • Make callback the single source of truth for the token, or read turnstile.getResponse(widgetId) at submit time rather than holding a copy. turnstile.isExpired(widgetId) tells you whether the current token is still good.
  • Auto-refresh cannot help when the widget is not there to refresh. An SPA route change that unmounts the widget, or a turnstile.remove() call, leaves you holding a token that will age out.
  • Backgrounded and suspended tabs throttle timers. A form filled at 09:00 and submitted when the visitor returns at 09:20 can carry a token that never got refreshed.
  • Long uploads spend the window in flight. Get the token as late as possible — execution: "execute" defers it until you call turnstile.execute() — or refresh immediately before you start the upload.

One inconsistency to be aware of: Cloudflare's own error table attributes "invalid, malformed, or expired" to invalid-input-response, while the prose in the same document attributes an expired token to timeout-or-duplicate. Handle both codes identically — reset the widget and ask for a fresh token — and you do not need to care which one arrives.

Reproduce it on demand with the test secret key

Cloudflare publishes a secret key whose only job is to return this error, which makes the failure path testable without waiting five minutes or replaying a real token. Pair it with the dummy token that the test sitekeys produce.

bash
# The 3x... test secret key always answers timeout-or-duplicate.
curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d secret=3x0000000000000000000000000000000AA \
  -d response=XXXX.DUMMY.TOKEN.XXXX

# {"error-codes":["timeout-or-duplicate"],"success":false,"messages":[],
#  "metadata":{"result_with_testing_key":true}}

# The 1x... key always passes, and dummy tokens are NOT single-use there,
# so you cannot reproduce a real replay by calling this one twice.
curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d secret=1x0000000000000000000000000000000AA \
  -d response=XXXX.DUMMY.TOKEN.XXXX

Point your development environment at the 3x key and drive your form. If the UI leaves the visitor stuck, or the submit button comes back enabled while the old token is still in state, you have the retry dead-end. Fix it there rather than in production.

The same rules apply to automated testing

If you drive your own signup, login or checkout flow from CI, synthetic monitoring or an anti-bot test harness, nothing about the token model changes. One token per submission, no caching between runs, no sharing a token across parallel workers. A token reused by a second worker fails with timeout-or-duplicate, and the flaky test that results will look like a race condition in your application.

SolveGate exists for that case: one POST /v1/solve with the gate, sitekey and page URL returns a fresh Turnstile token for a widget on a property you own or are authorised to test. Failed solves are never billed. Each Turnstile token is spent when your endpoint verifies it. The separate WAF gate returns cookies and headers, with different reuse rules described in Supported gates.

Common questions

It is a siteverify error code meaning the token you submitted cannot be redeemed. Cloudflare describes it as "Token has already been validated". It appears when the same token is sent to siteverify more than once, or when the token was generated more than 300 seconds before you verified it.

300 seconds, or five minutes, from generation. The clock starts when the widget produces the token, not when the visitor submits the form. After that window siteverify rejects it and the widget must be refreshed to generate a new one.

No. Each token can be validated exactly once, and a replayed token is rejected with timeout-or-duplicate. If two parts of your stack both call siteverify — middleware and a route handler, for example — the second call always fails. Verify once and pass the result forward.

Call turnstile.reset(widgetId) with the ID returned by turnstile.render(). The widget solves again and invokes your callback with a fresh token. Clear whatever copy of the old token your application is holding at the same time, and keep the submit control disabled until the new token arrives.

It addresses one specific cause: retrying the siteverify HTTP request after a timeout, where Cloudflare may already have redeemed the token. Cloudflare documents idempotency_key as a UUID you generate to safely retry validation requests. Generate it once per token and send the same value on every retry of that token. It does not let you verify a token twice for two different submissions.

Something verified the token before your handler did, or the token was already stale. The usual culprits are a second verification path such as middleware or a framework auth callback that fires twice, a client-side effect that submits twice, and the test secret key 3x0000000000000000000000000000000AA, which is designed to always return this code. Log every siteverify call keyed by token to see which one applies.

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 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.cf-turnstile-response invalidThree different failures wear this name. Sort out which one you have — the token never arrived, siteverify refused it, or your keys do not match.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