Understand the four-step request flow
First, the React page renders Turnstile with the public site key. Second, the successful widget callback gives the browser a token. Third, the form sends that token and the form fields to your API. Fourth, the API calls Siteverify with the secret key and accepts the business request only when validation succeeds.
The browser must never receive the secret key. A disabled submit button is user experience, not the security boundary; the API must reject a missing or invalid token.
Keep widget lifecycle and form state separate
Store the Turnstile token in component state and clear it after every submission attempt that consumes the token. Reset the widget after an expired token or a server response that requires a fresh challenge.
React Strict Mode and route changes can cause effects to run more than once during development. Use the integration library's lifecycle support or an explicit container reference so duplicate widgets are not appended.
const [token, setToken] = useState("");
<Turnstile
sitekey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
onVerify={setToken}
onExpire={() => setToken("")}
onError={() => setToken("")}
/>
<button disabled={!token || pending}>Send</button>
Send the token in the same protected request
Include the token with the fields the API is about to process. Do not validate in a separate browser request and then trust a client-side flag; that creates a gap between validation and the protected action.
For a contact form, the backend should validate before sending email. For registration, validate before creating the account. For checkout, place validation before the expensive or abuse-sensitive step defined by the application's risk model.
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, turnstileToken: token }),
});
Make Siteverify the backend gate
The API posts the secret and response token to Siteverify. Treat a network error, malformed response or success:false as a failed validation unless your risk model explicitly defines another behavior.
Where configured, compare hostname and action to the expected form. This prevents a valid token from another widget or flow being accepted blindly.
const body = new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY!,
response: turnstileToken,
});
const result = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{ method: "POST", body }
).then((response) => response.json());
if (!result.success) return new Response("Verification failed", { status: 400 });
Handle expiry, retries and double submissions
Turnstile tokens expire after five minutes and are single-use. A user who waits on the page or retries after the backend already consumed the token needs a new challenge. Map timeout-or-duplicate to a clear retry message rather than a generic form failure.
Disable repeated submissions while the request is pending, but keep server idempotency for actions where duplicate processing would be harmful. Turnstile token rules do not replace application idempotency.
Production testing checklist
Use Cloudflare's documented testing keys in automated or non-production scenarios and real environment-specific keys for the deployed hostname. Verify the direct API path as well as the visible form.
- ▸ Missing token is rejected
- ▸ Invalid and expired tokens are rejected
- ▸ A used token cannot be replayed
- ▸ The production hostname is allowed
- ▸ CSP permits required Turnstile resources
- ▸ Keyboard, mobile and slow-network states remain usable