A public contact form is the easiest door into your inbox. The moment it is indexed, bots find it, and a plain honeypot field stops only the laziest of them. A challenge from Cloudflare Turnstile is the pragmatic middle ground: it runs on Cloudflare's edge next to your Worker, it is free at any realistic volume, and most visitors never notice it.
This is the integration that survived contact with a real form on a TanStack Start app running on Cloudflare Workers — including the part nobody warns you about, where a perfectly verified token becomes a liability.
Why Turnstile instead of reCAPTCHA
- No Google dependency. Your visitors are not loaded with a third-party script from an advertising company, and you do not hand a contact form's traffic to one.
- It suits Workers. Server-side verification is a single
fetchto Cloudflare's own endpoint, so you never leave the network you are already on. - It is a managed challenge. You do not tune difficulty levels or puzzle types. You render a widget, and either you receive a token or you do not.
- Test keys are first class. Cloudflare publishes dummy sitekeys and secrets for always-pass, always-block and forced-interaction scenarios, so the whole flow is testable without touching production traffic.
The trade-off is that you are decorating somebody else's HTML inside a cross-origin iframe. That constraint drives most of the code below.
Render explicitly, and load the script once
The default Turnstile snippet scans your page for .cf-turnstile elements. In a React app that fights you: the widget has to appear, disappear and reappear on its own schedule. Render it explicitly instead, and load api.js exactly once no matter how many components ask for it.
const SCRIPT_ID = "cloudflare-turnstile-script"
const SCRIPT_SRC =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
let pending: Promise<Turnstile> | null = null
export function loadTurnstile(): Promise<Turnstile> {
if (window.turnstile) return Promise.resolve(window.turnstile)
if (pending) return pending
const request = new Promise<Turnstile>((resolve, reject) => {
const script =
document.getElementById(SCRIPT_ID) ?? document.createElement("script")
if (!script.isConnected) {
script.id = SCRIPT_ID
script.src = SCRIPT_SRC
script.async = true
script.defer = true
document.head.appendChild(script)
}
const handleLoad = () => {
if (window.turnstile) {
resolve(window.turnstile)
} else {
script.remove()
reject(new Error("unavailable"))
}
}
// A failed script has to leave the DOM: a later retry would otherwise reuse
// this node, re-attach listeners to an element that already fired, and hang
// forever without ever surfacing an error.
const handleError = () => {
script.remove()
reject(new Error("unavailable"))
}
script.addEventListener("load", handleLoad, { once: true })
script.addEventListener("error", handleError, { once: true })
})
pending = request
void request.then(
() => {
if (pending === request) pending = null
},
() => {
if (pending === request) pending = null
},
)
return request
}Two details earn their keep. The module-level promise means two mounted widgets share one in-flight load instead of appending two <script> tags. And clearing it on failure means a retry can genuinely retry: a rejected promise cached forever turns a flaky network into a dead form.
The widget is a cross-origin iframe
Everything inside the widget is rendered by Cloudflare, so your CSS stops at the iframe border. Turnstile's theme option is the only styling lever you get, and it accepts exactly three values:
| Value | What it follows |
|---|---|
auto (default) | The operating system preference |
light | A light widget, regardless of your site |
dark | A dark widget, regardless of your site |
auto is the trap. Your site's mode is stored in your own state and may be either a manual choice or a system setting; auto knows nothing about it. Pass the theme your page actually resolved, so a reader who picked dark mode on a light-OS laptop does not get a white box in the middle of a dark form.
theme: resolvedTheme === "dark" ? "dark" : "light"Rebuild on theme change, and release the token
Turnstile fixes the theme at render time. There is no update call, so the only way to follow a live theme switch is to throw the widget away and render a new one. Add theme to the effect's dependencies and clean up properly:
useEffect(() => {
let active = true
let widgetId: string | null = null
void loadTurnstile().then((turnstile) => {
if (!active || !containerRef.current) return
widgetId = turnstile.render(containerRef.current, {
sitekey,
action: "contact_submit",
size: "flexible",
language,
theme,
callback: (token) => active && onTokenChange(token),
"expired-callback": () => onTokenChange(null),
"error-callback": () => onUnavailable(),
})
})
return () => {
active = false
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId)
// The replacement starts unchecked, so the form must not keep holding a
// verification the reader can no longer see.
onTokenChange(null)
}
}
}, [sitekey, language, theme])That onTokenChange(null) is the part worth arguing about, because it is easy to treat as over-engineering — right up to the first time it matters. A rebuilt widget starts unchecked. If the form still holds the old token, Submit stays enabled, and a visitor who switches theme mid-form can submit a form whose visible challenge is blank. Releasing the token on teardown keeps the visual state and the form state telling the same story, and re-verification costs the visitor one click or none at all.
While you are there, let the effect depend on siteKey and language too. A locale switch has to rebuild the widget for exactly the same reason.
Verify the token on the server
The browser tells you a challenge succeeded. That is a claim, not evidence, so the token is worthless until your Worker validates it:
const outcome = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: token,
remoteip: request.headers.get("cf-connecting-ip") ?? undefined,
}),
},
).then((response) => response.json<{ success: boolean }>())
if (!outcome.success) {
return new Response("verification_failed", { status: 400 })
}The body accepts secret, response and an optional remoteip, plus an optional idempotency_key (a UUID you generate) so a retried request cannot be confused with a replayed token. Tokens are single-use: validate once, then treat the result as your own record. Never store the token as proof of anything, and never let the browser decide whether a submission was legitimate — if siteverify is unreachable, reject the submission rather than skipping the check.
Turnstile is also not a rate limiter. Keep hashed-IP and per-email counters next to the verification, so one solved challenge cannot be replayed into a thousand messages. And keep the secret out of the client bundle: it belongs in the Worker's environment, and it should be independent of every other secret in the app.
Fail closed, in the reader's language
Every failure path here is user-visible, which makes it copy, not plumbing. If the script will not load, if siteverify is down, or if the reader never completes the challenge, the form must refuse to submit and say why in the language they are reading. Wire those messages through the same translation layer as the rest of the page, including the accessible label on the widget container, so the challenge is not an unlabelled iframe for screen-reader users.
Test with dummy keys
Cloudflare publishes test sitekeys and test secrets that produce predictable outcomes — always pass, always block, or force interaction — which means you can exercise both the happy path and the rejection path locally, and your test suite never makes a real challenge call. Production secrets deliberately reject dummy tokens, so a stray test key cannot quietly weaken the deployed form. The list lives in Cloudflare's Turnstile testing documentation.
Checklist
- Render explicitly, and load
api.jsonce behind a shared promise. - Pass your resolved theme, not
auto, and rebuild the widget when the theme or the locale changes. - Release the token whenever the widget is replaced or removed.
- Validate every token with
siteverifyon the server, and fail closed when it cannot be reached. - Keep hashed-IP and per-email limits beside the check, and keep the secret in the Worker environment.
- Translate every failure message, and label the widget container.
Done properly, the challenge costs the visitor a single click at most, and the form behind it stops being a firehose.



