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, typically in under 1.5 seconds, for widgets and Turnstile WAF challenge pages on properties you own or are authorised to test. Failed solves are never billed. Treat every token it returns the way the browser's would be treated — spent the moment your endpoint verifies it.

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

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