A Cloudflare Turnstile token that passes in the browser and then gets a 403 or timeout-or-duplicate from Python requests is rarely a bad token. It is a good token used the wrong way: a second time, after its 300-second lifetime, for a different sitekey or action, inside a form that lost its session, or sent to an endpoint where Cloudflare challenges the client before the application ever reads the token.
Each of those failures looks different on the wire. This guide shows how to tell them apart from the response, how to reproduce timeout-or-duplicate with Cloudflare's own test keys, and a submit flow in Python and Node.js that mints one token per request and never reuses it.
Quick diagnosis
| What you get back | What it means | Fix |
|---|---|---|
"error-codes": ["timeout-or-duplicate"] |
The token was already validated once, or it expired | One new token per submit, including retries, posted within seconds |
"error-codes": ["invalid-input-response"] |
Token malformed, truncated, expired, or minted for a different sitekey | Solve for the sitekey on the rendered page and post within seconds |
| The app says "captcha failed", no error code | The backend compared hostname or action, or the token went into the wrong field |
Pass the exact page URL and the widget's action; match the field name |
| A 403 or 419 page from the app itself | CSRF token, session cookie or hidden fields missing | GET the form in the same session and send every hidden input |
Header cf-mitigated: challenge
|
Cloudflare challenged the client; the app never saw your token | Get cf_clearance and reuse it with the same IP, User-Agent and TLS fingerprint |
Error 1020 or "Sorry, you have been blocked" |
A firewall rule matched the request | No token changes this |
The rest of the article explains each row and ends with code that handles all of them.
How Turnstile validation works, and why the browser succeeds
A Turnstile token is a one-time proof, up to 2,048 characters long, that the widget produced for one sitekey on one hostname. The flow has three steps:
- The widget runs in the page and writes the token into a hidden input named
cf-turnstile-response(a site can rename it withdata-response-field-name). - The form is submitted, and the site's backend sends the token and its secret key to
https://challenges.cloudflare.com/turnstile/v0/siteverify. - Siteverify answers with
success,error-codes,challenge_ts,hostname,actionandcdata. The backend decides what to do with that answer.
Three rules from Cloudflare's server-side validation docs drive every failure below:
- A token is valid for 300 seconds from generation.
- A token can be validated once. A replayed token is rejected with
timeout-or-duplicate. - A token is at most 2,048 characters. Anything that truncates it (a fixed-width column, a log line you copied it from) makes it invalid.
The browser gets all of this right by default. It submits once, seconds after the token was minted, from the page that minted it, with the cookies and hidden fields the form expects. The widget also refreshes an expired token on its own (refresh-expired defaults to auto). A script that copies the token out of the page loses all of that.
One more thing to keep in mind: you never talk to siteverify. The backend does. Whether you see the raw error-codes, a generic "verification failed" message, or a bare 403 depends entirely on how that backend was written.
Step 1: classify the failing response
Before changing anything, find out which layer rejected the request. This function looks at the response in the order that matters:
import re
BLOCK_PAGE = re.compile(r"error(?: code)?:? ?10\d\d|sorry, you have been blocked", re.I)
def diagnose(resp):
"""Return which layer rejected a form POST."""
if resp.headers.get("cf-mitigated") == "challenge":
return "cloudflare-challenge" # Cloudflare answered; the app never saw the token
if BLOCK_PAGE.search(resp.text[:5000]):
return "cloudflare-block" # WAF rule such as 1020; no token helps
try:
body = resp.json()
except ValueError:
body = None
codes = body.get("error-codes") if isinstance(body, dict) else None
if codes:
return "token-rejected: " + ",".join(codes)
if resp.status_code in (400, 401, 403, 419, 422):
return "app-rejected" # CSRF/session/fields, or a token error the app hides
return "ok" if resp.ok else f"http-{resp.status_code}"
Call it as print(resp.status_code, diagnose(resp)) right after the failing POST. Typical output for the three most common cases:
403 cloudflare-challenge
200 token-rejected: timeout-or-duplicate
403 app-rejected
Don't use the server: cloudflare or cf-ray headers for this. Every response from a proxied site carries them, including the application's own 403 pages. cf-mitigated: challenge is the header Cloudflare documents for challenge pages, and it is the reliable signal.
Step 2: reproduce the error codes with Cloudflare's test keys
If you own the backend, or you want a test for your client's retry logic, Cloudflare publishes test keys. Test sitekeys make the widget return the dummy token XXXX.DUMMY.TOKEN.XXXX, and test secret keys make siteverify return a fixed answer:
import requests
VERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
TEST_SECRETS = {
"always passes": "1x0000000000000000000000000000000AA",
"always fails": "2x0000000000000000000000000000000AA",
"already spent": "3x0000000000000000000000000000000AA",
}
for label, secret in TEST_SECRETS.items():
r = requests.post(VERIFY, data={"secret": secret, "response": "XXXX.DUMMY.TOKEN.XXXX"}, timeout=30)
body = r.json()
print(f"{label:14} success={body['success']} error-codes={body['error-codes']}")
Output:
always passes success=True error-codes=[]
always fails success=False error-codes=['invalid-input-response']
already spent success=False error-codes=['timeout-or-duplicate']
The front-end test sitekeys are 1x00000000000000000000AA (always passes), 2x00000000000000000000AB (always fails) and 3x00000000000000000000FF (forces an interactive challenge). Production secret keys reject the dummy token, so pair test sitekeys with test secrets.
Cause 1: the token was already used (timeout-or-duplicate)
Cloudflare's docs describe timeout-or-duplicate as "Token has already been validated". Scripts usually spend a token twice in one of these ways:
-
The browser submitted it first. Playwright clicks Submit, or the page submits itself from the widget callback, and then your script reads the token from the DOM and replays it with
requests. -
Retry logic resends the same payload. A
tenacitydecorator or afor attempt in range(3)loop aroundsession.post(url, data=form)retries with the old token still insideform. The first attempt may have reached siteverify even though it timed out on your side. - A validation call you didn't notice. Some front ends validate the token with an XHR request when the widget completes and then post the form. If your script repeats both requests with one token, siteverify sees it twice. The Network tab in DevTools shows these calls.
- A shared token pool. Several workers read tokens from one queue and two of them get the same one.
- Testing by hand. Pasting the token into curl to see whether it works uses it up.
The fix is a rule, not a setting: a token belongs to exactly one HTTP request. Every retry mints a new one.
Cause 2: the token expired after 300 seconds (timeout-or-duplicate or invalid-input-response)
The 300 seconds run from generation, not from the moment your code received the token. A token from a solver has already aged a little by the time your poll picks it up, and a pipeline that prepares tokens ahead of time burns the rest.
Cloudflare's docs name both codes for an expired token: the error-code table lists expiry under invalid-input-response ("Token is invalid, malformed, or expired"), while the same page says a token submitted after five minutes fails with timeout-or-duplicate. Backends also pass through different codes. Treat both the same way: request a fresh token instead of debugging the old one.
The simplest guard is ordering. Fetch the form, build the payload, and solve last, so the only thing between the solve and the POST is one function call. If your flow has a slow step you cannot move, check the token's age before you send it:
import time
TOKEN_BUDGET = 120 # seconds; well inside the 300 s lifetime
# solve_turnstile() is defined in the full flow further down
token, received_at = solve_turnstile(SITEKEY, FORM_URL, ACTION), time.monotonic()
payload = build_payload() # stands in for the slow step you can't move
if time.monotonic() - received_at > TOKEN_BUDGET:
token, received_at = solve_turnstile(SITEKEY, FORM_URL, ACTION), time.monotonic()
Cause 3: the sitekey, hostname or action don't match
A token is minted for one widget configuration. Check these four things:
- Multiple widgets. A login form and a newsletter form on the same page can use different sitekeys. Solve for the widget inside the form you submit.
-
Sitekeys set by JavaScript. When a site calls
turnstile.render('#el', { sitekey: ... }), the key is not in the static HTML, and it can change between deploys. Read it from the rendered page, not from arequests.getof the HTML. -
The page URL. Pass the URL of the page that shows the widget, not the POST endpoint or the site root. Siteverify returns the
hostnamethe token was issued on, and a backend may compare it. -
The action. A widget can carry
data-action(up to 32 characters: letters, digits,_and-). Siteverify returns it, and a backend that checks it rejects a token minted without it or with a different value.
This Playwright snippet reads all of it from the rendered widget, including a renamed response field:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://target.example/login", wait_until="networkidle")
widget = page.evaluate("""() => {
const el = document.querySelector('.cf-turnstile, [data-sitekey]');
return el && {
sitekey: el.dataset.sitekey,
action: el.dataset.action || null,
field: el.dataset.responseFieldName || 'cf-turnstile-response',
};
}""")
print(widget)
browser.close()
Expected output:
{'sitekey': '0x4AAAAAAA...', 'action': 'login', 'field': 'cf-turnstile-response'}
If it prints None, the site renders the widget from JavaScript without data attributes. Search the page's scripts for turnstile.render( and read the sitekey and action from the options object.
Cause 4: the 403 comes from the form, not from Turnstile
This one is easy to miss because the token gets the blame. A browser form carries more than the token, and a script that sends only cf-turnstile-response plus the visible fields usually drops something:
- A CSRF token in a hidden input, tied to a session cookie the browser received when it loaded the page. Django answers a CSRF failure with 403, Laravel with 419.
- Session cookies set by the GET of the form page.
- Other hidden inputs: form IDs, timestamps, honeypot fields that must stay empty.
-
The encoding. An endpoint that the browser calls with a JSON
fetchmay rejectapplication/x-www-form-urlencoded, and the reverse is also true. -
The field name.
data-response-field-namerenamescf-turnstile-response, and some front ends move the token into a JSON key or a header before sending.
The fastest check is to open DevTools, find the browser's successful request in the Network tab, use "Copy as cURL", and diff it against what your script sends. Then load the form with the same session you post with. The full flow below does that with a small stdlib parser:
from html.parser import HTMLParser
class _HiddenInputs(HTMLParser):
def __init__(self):
super().__init__()
self.fields = {}
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "input" and (a.get("type") or "").lower() == "hidden" and a.get("name"):
self.fields[a["name"]] = a.get("value") or ""
def hidden_inputs(html):
"""Every hidden input in the page: CSRF token, form IDs, honeypots."""
parser = _HiddenInputs()
parser.feed(html)
return parser.fields
If the page has more than one form, feed the parser only the HTML of the form you submit, so another form's hidden fields don't end up in your payload.
Cause 5: Cloudflare challenges the client before the app sees the token
If the response has cf-mitigated: challenge, Cloudflare answered on the site's behalf. The request never reached the application, so the token in your body was never checked. This happens when the site puts a managed challenge in front of the endpoint, and a plain requests client, with its default TLS fingerprint and no cf_clearance cookie, is exactly the kind of client that challenge is set up to stop.
A better Turnstile token won't fix this. You need the clearance cookie, sent the way the browser that earned it sent it:
from curl_cffi import requests as cffi # pip install curl_cffi
session = cffi.Session(impersonate="chrome") # Chrome-like TLS and HTTP/2 fingerprint
session.headers["User-Agent"] = BROWSER_UA # the exact UA that received cf_clearance
session.cookies.set("cf_clearance", CF_CLEARANCE, domain=".target.example")
resp = session.post(POST_URL, data=payload, timeout=30)
print(resp.status_code, resp.headers.get("cf-mitigated"))
Take BROWSER_UA and CF_CLEARANCE from the same browser session (in Playwright, page.evaluate("navigator.userAgent") and context.cookies()), and send the request from the same IP. When the clearance stops working you will see cf-mitigated: challenge again, which means "get a new cookie", not "retry". I went through the difference between the two challenge types in managed vs interactive Cloudflare challenge: how to tell from the response, and the cookie's lifecycle in why cf_clearance expires and how to stop the re-challenge loop.
Cause 6: a firewall rule, not a captcha
Error 1020 ("Access denied") or a "Sorry, you have been blocked" page with a Ray ID means a WAF rule matched the request: an IP range, a country, a path or a request rate. There is no challenge to complete, so no token or cookie changes the result. Slow down, check whether the site offers an API for what you need, or ask the site owner.
The fixed flow in Python
This puts the pieces together: load the form in a session, solve last, post immediately, classify the response, and mint a new token if the token layer rejected it. The solve step uses a 2Captcha-style in.php/res.php token API; action is an optional parameter, sent only when the widget has one.
Prerequisites: Python 3.9+, pip install requests. Inputs: the page URL, the POST URL, and the sitekey, action and field name from Cause 3. It reuses diagnose() from Step 1 and hidden_inputs() from Cause 4.
import time
import requests
API_KEY = "YOUR_API_KEY"
SOLVER = "https://ocr.captchaai.com" # 2Captcha-style in.php / res.php API
FORM_URL = "https://target.example/login" # the page that shows the widget
POST_URL = "https://target.example/login" # where the form posts
SITEKEY = "0x4AAAAAAA..." # from the rendered widget
ACTION = None # the widget's data-action, if it has one
TOKEN_FIELD = "cf-turnstile-response" # or the widget's data-response-field-name
RETRYABLE = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}
class SolveError(RuntimeError):
pass
def check_config():
if API_KEY == "YOUR_API_KEY" or "..." in SITEKEY or "target.example" in FORM_URL + POST_URL:
raise SystemExit("Fill in API_KEY, SITEKEY, FORM_URL and POST_URL before running.")
def _call(method, url, **kw):
"""One solver API call, with backoff on network and transient server errors."""
err = None
for attempt in range(4):
if attempt:
time.sleep(min(30, 5 * 2 ** attempt)) # 10 s, 20 s, 30 s
try:
body = requests.request(method, url, timeout=30, **kw).json()
except (requests.RequestException, ValueError) as exc:
err = type(exc).__name__ # not str(exc): it can contain the URL and key
continue
if body.get("status") == 1 or body.get("request") not in RETRYABLE:
return body
err = body["request"]
raise SolveError(f"solver unreachable: {err}")
def solve_turnstile(sitekey, pageurl, action=None, timeout=120):
data = {"key": API_KEY, "method": "turnstile", "sitekey": sitekey,
"pageurl": pageurl, "json": 1}
if action:
data["action"] = action
sub = _call("POST", f"{SOLVER}/in.php", data=data)
if sub["status"] != 1:
# ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_WRONG_SITEKEY, ERROR_PAGEURL, ...
raise SolveError(f"submit rejected: {sub['request']}")
deadline = time.monotonic() + timeout
time.sleep(15)
while time.monotonic() < deadline:
res = _call("GET", f"{SOLVER}/res.php",
params={"key": API_KEY, "action": "get", "id": sub["request"], "json": 1})
if res["status"] == 1:
return res["request"]
if res["request"] != "CAPCHA_NOT_READY":
raise SolveError(f"solve failed: {res['request']}") # e.g. ERROR_CAPTCHA_UNSOLVABLE
time.sleep(5)
raise SolveError(f"no token after {timeout}s")
def submit(fields, max_tokens=2):
check_config()
session = requests.Session()
for attempt in range(1, max_tokens + 1):
page = session.get(FORM_URL, timeout=30) # cookies + CSRF for this attempt
if page.headers.get("cf-mitigated") == "challenge":
print("form page is behind a Cloudflare challenge: see Cause 5")
return page, "cloudflare-challenge" # a token can't help, so don't solve
payload = {**hidden_inputs(page.text), **fields}
payload[TOKEN_FIELD] = solve_turnstile(SITEKEY, FORM_URL, ACTION) # solve last
resp = session.post(POST_URL, data=payload, timeout=30) # post immediately
verdict = diagnose(resp)
print(f"attempt {attempt}: HTTP {resp.status_code} {verdict}")
if not verdict.startswith("token-rejected"):
return resp, verdict
# that token is spent either way: loop and mint a new one, never resend it
return resp, verdict
if __name__ == "__main__":
submit({"username": "your-user", "password": "your-password"})
Expected output on success:
attempt 1: HTTP 200 ok
When the first token was spent before it arrived (Cause 1), the loop mints a second one instead of resending it:
attempt 1: HTTP 200 token-rejected: timeout-or-duplicate
attempt 2: HTTP 200 ok
And when the POST endpoint sits behind a managed challenge (Cause 5):
attempt 1: HTTP 403 cloudflare-challenge
In that case, replace requests.Session() in submit() with the curl_cffi session from Cause 5. It exposes the same get, post, .text and .json() calls, so nothing else changes.
The same flow in Node.js
Node 20+, no dependencies (built-in fetch). Save as submit.mjs and run node submit.mjs.
const API_KEY = "YOUR_API_KEY";
const SOLVER = "https://ocr.captchaai.com";
const FORM_URL = "https://target.example/login";
const POST_URL = "https://target.example/login";
const SITEKEY = "0x4AAAAAAA...";
const ACTION = null;
const TOKEN_FIELD = "cf-turnstile-response";
const RETRYABLE = new Set(["ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function call(url, init = {}) {
let err;
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt) await sleep(Math.min(30_000, 5_000 * 2 ** attempt)); // 10 s, 20 s, 30 s
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(30_000) });
const body = await res.json();
if (body.status === 1 || !RETRYABLE.has(body.request)) return body;
err = body.request;
} catch (e) {
err = e.name; // not e.message: it can contain the URL and key
}
}
throw new Error(`solver unreachable: ${err}`);
}
async function solveTurnstile(sitekey, pageurl, action, timeoutMs = 120_000) {
const form = new URLSearchParams({ key: API_KEY, method: "turnstile", sitekey, pageurl, json: "1" });
if (action) form.set("action", action);
const sub = await call(`${SOLVER}/in.php`, { method: "POST", body: form });
if (sub.status !== 1) throw new Error(`submit rejected: ${sub.request}`);
const deadline = Date.now() + timeoutMs;
await sleep(15_000);
while (Date.now() < deadline) {
const q = new URLSearchParams({ key: API_KEY, action: "get", id: sub.request, json: "1" });
const res = await call(`${SOLVER}/res.php?${q}`);
if (res.status === 1) return res.request;
if (res.request !== "CAPCHA_NOT_READY") throw new Error(`solve failed: ${res.request}`);
await sleep(5_000);
}
throw new Error(`no token after ${timeoutMs / 1000}s`);
}
const ENTITIES = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" };
const decode = (s) =>
s.replace(/&(?:#(\d+)|#x([\da-f]+)|(amp|quot|apos|lt|gt));/gi, (_, dec, hex, name) =>
name ? ENTITIES[name.toLowerCase()] : String.fromCodePoint(dec ? Number(dec) : parseInt(hex, 16)));
function attr(tag, name) {
// quoted or unquoted attribute value; the leading \s keeps data-name from matching name
const m = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
return m ? decode(m[1] ?? m[2] ?? m[3]) : undefined; // "a&b" in HTML is "a&b" on the wire
}
function hiddenInputs(html) {
const out = {};
for (const tag of html.match(/<input\b[^>]*>/gi) ?? []) {
if (attr(tag, "type")?.toLowerCase() !== "hidden") continue;
const name = attr(tag, "name");
if (name) out[name] = attr(tag, "value") ?? "";
}
return out;
}
function diagnose(res, text) {
if (res.headers.get("cf-mitigated") === "challenge") return "cloudflare-challenge";
if (/error(?: code)?:? ?10\d\d|sorry, you have been blocked/i.test(text.slice(0, 5000))) {
return "cloudflare-block";
}
let codes = [];
try {
codes = JSON.parse(text)["error-codes"] ?? [];
} catch {}
if (Array.isArray(codes) && codes.length) return `token-rejected: ${codes.join(",")}`;
if ([400, 401, 403, 419, 422].includes(res.status)) return "app-rejected";
return res.ok ? "ok" : `http-${res.status}`;
}
async function submit(fields, maxTokens = 2) {
let verdict;
for (let attempt = 1; attempt <= maxTokens; attempt++) {
const page = await fetch(FORM_URL, { signal: AbortSignal.timeout(30_000) });
if (page.headers.get("cf-mitigated") === "challenge") {
console.log("form page is behind a Cloudflare challenge: see Cause 5");
return "cloudflare-challenge"; // a token can't help, so don't solve
}
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const payload = new URLSearchParams({ ...hiddenInputs(await page.text()), ...fields });
payload.set(TOKEN_FIELD, await solveTurnstile(SITEKEY, FORM_URL, ACTION)); // solve last
const res = await fetch(POST_URL, {
method: "POST",
body: payload,
headers: cookie ? { cookie } : {},
signal: AbortSignal.timeout(30_000),
});
verdict = diagnose(res, await res.text());
console.log(`attempt ${attempt}: HTTP ${res.status} ${verdict}`);
if (!verdict.startsWith("token-rejected")) return verdict;
}
return verdict;
}
if (API_KEY === "YOUR_API_KEY" || SITEKEY.includes("...") || (FORM_URL + POST_URL).includes("target.example")) {
console.error("Fill in API_KEY, SITEKEY, FORM_URL and POST_URL before running.");
process.exit(1);
}
await submit({ username: "your-user", password: "your-password" });
The output format is the same as the Python version:
attempt 1: HTTP 200 ok
Checklist
- Response classified first:
cf-mitigated, a 1020 page,error-codes, or an error page from the app. - One token per HTTP request, retries included. Nothing else (the browser, a validation XHR, another worker) used it first.
- Posted within seconds of the solve, with nothing slow in between.
- Sitekey,
actionand field name read from the rendered widget;pageurlis the page that shows it. - Form loaded in the same session: cookies, CSRF token and every hidden input sent, with the same encoding the browser uses.
- If challenged:
cf_clearancesent with the same User-Agent and IP, and a browser-like TLS fingerprint.
FAQ
How long is a Cloudflare Turnstile token valid?
300 seconds from generation, and it can be validated only once. In the browser the widget refreshes it automatically. A script has to request a new one.
What do timeout-or-duplicate and invalid-input-response mean?
timeout-or-duplicate means siteverify has already seen this token: something in your flow used it before your request did, or a retry sent it again. Cloudflare's docs also return it for a token older than 300 seconds. invalid-input-response means the token is invalid, malformed or expired, so request a fresh one and post it within seconds.
Why does the same token work in the browser?
The browser submits it once, seconds after it was minted, from the page that minted it, with the form's cookies and hidden fields. If the site also runs a managed challenge, the browser already holds the cf_clearance cookie and the fingerprint Cloudflare accepted.
Can I reuse one Turnstile token across requests or threads?
No. One token, one siteverify call. Budget one solve per submit.
Is the field always called cf-turnstile-response?
It is the default. A site can rename it with data-response-field-name, or its JavaScript can send the token in a JSON body or a header. Copy the browser's real request to be sure.
Disclosure: the solver endpoint in these examples is CaptchaAI, which I run. If you want to try this flow against your own form, there is one thread free for 30 days, no card, and the Cloudflare Turnstile guide covers the request format and error codes. Whichever solver you use, the rule is the same: read the response first, then give every request its own fresh token.
Top comments (0)