research/cloudflare-turnstile-integration-checklist
A Cloudflare Turnstile Integration Checklist for Contact Forms
Engineering Note · October 27, 2026 · 10 minute read · Turnstile · Forms · Cloudflare
Cloudflare Turnstile is a bot check for forms that replaces a CAPTCHA with a token your server verifies. Integrating it correctly means rendering the widget, sending its token with the form, and having your server confirm that token with Cloudflare’s siteverify endpoint before it does any work.
Adding Turnstile to a contact form takes a few minutes, and getting it subtly wrong takes about the same. The widget looks finished the moment it renders, but the integration is only complete when the server checks the result. This is the checklist we use for every form we ship, with the reason behind each item.
How do you verify a Turnstile token on the server?
The widget places a token in a hidden form field named cf-turnstile-response. A visible green checkmark in the browser proves nothing by itself, because anyone can post to your endpoint without ever loading the page.
The server must send that token, along with the secret key, to Cloudflare’s siteverify endpoint and read the answer. This snippet is illustrative, not executed here, because it calls the real service:
const body = new FormData();
body.append("secret", env.TURNSTILE_SECRET_KEY);
body.append("response", token);
const res = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{ method: "POST", body },
);
const outcome = await res.json<{ success: boolean }>();
if (!outcome.success) {
return Response.json({ error: "Verification failed" }, { status: 400 });
}
The secret key belongs in the Cloudflare dashboard as a secret, not in the repository. Only the site key, which is public by design, appears in the page.
Are Turnstile tokens single-use?
A token can be verified once and expires after a short time. Two consequences follow:
- If a submit fails for any reason, such as a validation error or a network blip, the old token is spent. Reset the widget so the visitor gets a fresh one, otherwise the retry fails verification and they have no idea why.
- A form left open for a while may hold an expired token. The server will reject it, so the client should be ready to ask again.
We reset the widget after every failed submit, whatever the cause. Cloudflare also documents an optional idempotency_key parameter (a UUID you generate) for safely retrying a validation request. We don’t use it; our rule is simply to get a fresh token after any failed submit.
Browser Your server siteverify
| widget renders | |
| token T1 issued | |
|-- POST form + T1 -------->| |
| |-- secret + T1 ---------->|
| |<-- success: true --------| T1 is now spent
| | validation fails |
|<-- 422 -------------------| |
| reset widget -> token T2 | |
|-- POST form + T2 -------->| |
| |-- secret + T2 ---------->|
| |<-- success: true --------|
|<-- 200 -------------------| |
Note the order in that diagram: verification happens before validation, so a validation error still spends the token. Without the reset, the second POST would carry T1 and be rejected.
What happens when a token is replayed, expired or forged?
We can’t call the real service from a test, so we built a local stand-in: a small Node HTTP server that mimics the documented behavior of siteverify. It issues tokens, accepts each one once, expires them after one second, and returns the error codes Cloudflare documents (missing-input-secret, invalid-input-secret, missing-input-response, invalid-input-response, timeout-or-duplicate). The mock’s one-second lifetime is ours, chosen to make the demo quick; the real service uses its own, which Cloudflare documents as five minutes.
It is a mock, so it shows how our form code reacts to those responses, not how Cloudflare behaves. The handler under test verifies first, then validates, as in our pipeline. We ran it with Node v22.22.2 and no dependencies:
// mock.mjs - a local stand-in for Turnstile siteverify. NOT the real service.
import http from "node:http";
import { randomUUID } from "node:crypto";
const SECRET = "mock-secret";
const TTL_MS = 1000; // the mock's lifetime; the real service uses its own
const issued = new Map(); // token -> { at, used }
const server = http.createServer(async (req, res) => {
if (req.url === "/issue") { // plays the role of the browser widget
const t = "tok-" + randomUUID();
issued.set(t, { at: Date.now(), used: false });
return res.end(t);
}
const form = await new Request("http://x", { method: "POST", headers: req.headers, body: req, duplex: "half" }).formData();
const secret = form.get("secret"), token = form.get("response");
const out = (o) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify(o)); };
if (!secret) return out({ success: false, "error-codes": ["missing-input-secret"] });
if (secret !== SECRET) return out({ success: false, "error-codes": ["invalid-input-secret"] });
if (!token) return out({ success: false, "error-codes": ["missing-input-response"] });
const rec = issued.get(token);
if (!rec) return out({ success: false, "error-codes": ["invalid-input-response"] });
if (rec.used || Date.now() - rec.at > TTL_MS) return out({ success: false, "error-codes": ["timeout-or-duplicate"] });
rec.used = true;
return out({ success: true, "error-codes": [] });
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const BASE = `http://127.0.0.1:${server.address().port}`;
// ---- the code under test: same shape as the verification snippet above ----
let verifyCalls = 0;
async function verify(token, secret = SECRET) {
verifyCalls++;
const body = new FormData();
body.append("secret", secret);
if (token !== undefined) body.append("response", token);
const res = await fetch(`${BASE}/siteverify`, { method: "POST", body });
return res.json();
}
// A form endpoint: honeypot, then verify, then validate.
async function handleSubmit(token, message, honeypot = "") {
if (honeypot) return { status: 200, why: "silently dropped" };
let v;
try { v = await verify(token); }
catch { return { status: 503, why: "verify: siteverify unreachable, request refused" }; }
if (!v.success) return { status: 400, why: "verify: " + v["error-codes"].join(",") };
if (!message || message.length < 5) return { status: 422, why: "validation: message too short" };
return { status: 200, why: "accepted" };
}
const issue = async () => (await fetch(`${BASE}/issue`)).text();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const rows = [];
const log = (scenario, r) => rows.push([scenario, r.status, r.why]);
let t = await issue();
log("1 fresh token, valid message", await handleSubmit(t, "Hello there"));
log("2 replay the same token", await handleSubmit(t, "Hello there"));
t = await issue();
log("3 fresh token, message too short", await handleSubmit(t, "hi"));
log("4 retry with SAME token (no reset)", await handleSubmit(t, "Hello there"));
t = await issue(); // widget reset gives a new token
log("5 retry after widget reset", await handleSubmit(t, "Hello there"));
t = await issue(); await sleep(TTL_MS + 200);
log("6 token older than mock TTL", await handleSubmit(t, "Hello there"));
log("7 empty token field", await handleSubmit("", "Hello there"));
log("8 forged token string", await handleSubmit("not-a-real-token", "Hello there"));
t = await issue();
const wrong = await verify(t, "wrong-secret");
log("9 wrong secret key", { status: wrong.success ? 200 : 400, why: "verify: " + wrong["error-codes"].join(",") });
const before = verifyCalls;
t = await issue();
log("10 honeypot field filled", await handleSubmit(t, "Hello there", "[email protected]"));
log("11 siteverify calls made by #10", { status: "-", why: String(verifyCalls - before) });
await new Promise((r) => server.close(r)); // take siteverify offline
log("12 siteverify unreachable", await handleSubmit("tok-any", "Hello there"));
console.log("node", process.version);
console.log("scenario".padEnd(38), "status", "result");
for (const [s, st, w] of rows) console.log(s.padEnd(38), String(st).padEnd(6), w);
node v22.22.2
scenario status result
1 fresh token, valid message 200 accepted
2 replay the same token 400 verify: timeout-or-duplicate
3 fresh token, message too short 422 validation: message too short
4 retry with SAME token (no reset) 400 verify: timeout-or-duplicate
5 retry after widget reset 200 accepted
6 token older than mock TTL 400 verify: timeout-or-duplicate
7 empty token field 400 verify: missing-input-response
8 forged token string 400 verify: invalid-input-response
9 wrong secret key 400 verify: invalid-input-secret
10 honeypot field filled 200 silently dropped
11 siteverify calls made by #10 - 0
12 siteverify unreachable 503 verify: siteverify unreachable, request refused
Failure modes
These are the cases the run above reproduced, and what to do about each:
| Symptom | Cause | Fix |
|---|---|---|
| Retry after a validation error returns 400 (rows 3 and 4) | The first attempt spent the token | Reset the widget on every failed submit (row 5) |
| Form left open returns 400 (row 6) | Token expired | Use the expiry callback to ask for a new one |
| Empty token field (row 7) | Widget not finished, or script blocked | Disable submit until a token exists, show a message |
| Forged string (row 8) | Someone posted without loading the page | Nothing to fix; this is why the server verifies |
| Wrong secret (row 9) | Misconfigured environment | Treat as a deploy error, not a visitor error |
| Cloudflare unreachable (row 12) | Network failure in the verify call | Refuse the request; never treat a failed call as a pass |
Row 12 is the one people forget. A fetch that throws must not fall through to “success”. In the demo it produces a refusal with a distinct 503 so it can be told apart from a bad token in logs.
Never hide the container once it is mounted
This one cost us time. If the widget’s container is set to display: none after it has mounted, Cloudflare invalidates a token that was already verified. The visitor did everything right and the submit still fails. We hit this in our own forms and did not reproduce it in the mock, which cannot model Cloudflare’s widget. Cloudflare’s documentation does not describe this behavior; it is what we observed, and a regression test now pins it.
Our forms collapse and expand, so we handle it differently. Instead of hiding the container, we collapse the form with a CSS class and set the inert attribute on it. The element stays rendered, keyboard focus can’t reach it while collapsed, and the token survives.
Tell the visitor what happened
The widget exposes callbacks for expiry and errors. Use them. When the token expires or the challenge errors, show a plain message asking the visitor to verify again, and keep the submit button from sending an empty token. A form that quietly fails is worse than one that says “please verify again.”
Which test keys work for previews?
Cloudflare publishes test keys that always pass. We use them as a fallback so preview deployments and local runs work without real keys configured. If no real keys are present, the form still functions. Per Cloudflare’s documentation (not exercised here, since it needs the real service), the always-pass pair is site key 1x00000000000000000000AA with secret 1x0000000000000000000000000000000AA, and there is an always-fail pair for testing the failure path.
The fallback has a limit worth stating: it covers Turnstile only. Real email delivery has no test fallback, so a preview without an email key can still verify a visitor but cannot send anything. Know which half you are testing.
Add a honeypot
A honeypot is a field that real visitors never see and never fill in. Hide it from view and from assistive technology, give it an unremarkable name, and silently drop any submission where it has a value. It catches the least sophisticated bots before they cost you a verification call, and it adds no friction for people. It supplements Turnstile rather than replacing it. Rows 10 and 11 above show the saving: a filled honeypot returned immediately and made zero siteverify calls.
Testing
In browser tests the widget is the awkward part. We mock the token field: the test sets the value of cf-turnstile-response directly, then submits. That exercises our form code and our server handling without depending on a third-party widget, a pattern covered in more depth in testing flows with third-party widgets.
Checklist
- Server verifies every token against siteverify before doing any work.
- Secret key stored as a dashboard secret only.
- Widget reset after every failed submit.
- Container never set to
display: noneafter mount; use a class plusinert. - Expiry and error callbacks show a clear message.
- A failed or unreachable verify call refuses the request.
- Test keys as a preview fallback, with real email configured separately.
- A hidden honeypot field.
- Browser tests set the token field directly.
Related reading
- Testing a form flow that depends on bot checks and AI
- Isolating Failures in a Form Pipeline: Save First, Notify Independently
- One Config for Client and Server: A Single Source of Truth for Intake Forms
Frequently asked questions
How do you verify a Turnstile token on the server?
Read the cf-turnstile-response field from the form, POST it with your secret key as secret and response to the siteverify endpoint at challenges.cloudflare.com, and parse the JSON. Proceed only if success is true. The widget alone proves nothing, because anyone can post to your endpoint without ever loading the page.
Can a Turnstile token be used more than once?
No. A token verifies once and expires after a short time. Cloudflare's documentation lists timeout-or-duplicate as the error for a replayed or expired token. In our local mock of siteverify, replaying a token returned that code, and so did retrying after a validation error, so reset the widget on every failed submit.
Why does my form fail Turnstile verification on the second try?
The first attempt spent the token, even if the submit failed later on validation or a network error. Retrying with the same token returns timeout-or-duplicate. Call the widget's reset after any failed submit so the visitor gets a fresh token, and show a clear message if the token expires or the challenge errors.
Why does hiding the Turnstile container break the token?
In our forms, setting the widget's container to display none after it had mounted made Cloudflare invalidate a token that had already been verified. We now collapse the form with a CSS class and set the inert attribute instead, which keeps the element rendered, blocks keyboard focus while collapsed, and keeps the token intact.
How do you test Turnstile locally and in preview deployments?
Use Cloudflare's published test keys as a fallback so the form works without real keys. The always-pass site key is 1x00000000000000000000AA with secret 1x0000000000000000000000000000000AA. This covers Turnstile only; real email delivery has no test fallback, so a preview can verify a visitor but may not be able to send mail.