Understand Turnstile Token Lifecycle
mainreset() method on the widget instance.repository·main·Indexed 21 days ago
https://github.com/marsidev/react-turnstileA lightweight, privacy-first React integration for Cloudflare Turnstile. It provides a Turnstile component for adding bot verification to web forms, supporting both declarative usage via props and an imperative API through the TurnstileInstance type with methods like reset(), execute(), getResponse(), and isExpired().
reset() method on the widget instance.Managing the Turnstile token is critical for successful form submissions:
rerenderOnCallbackChange={true}, wrap your callback functions in useCallback to prevent infinite re-render loops caused by stale closures or changing references.By default, the Turnstile widget's callbacks (like onSuccess) always access the latest state or props from your component without requiring the widget itself to re-render. This is achieved by the widget internally referencing the latest version of the function passed to it.
However, if your logic requires the widget to physically re-render (for example, to reset the widget or trigger a new challenge) whenever a callback function changes, you must explicitly enable this behavior using the rerenderOnCallbackChange prop.
// Default behavior: widget does NOT re-render when onSuccess changes,
// but the function itself will see the latest state.
<Turnstile siteKey="{{ siteKey }}" onSuccess={handleSuccess} />
// Re-render behavior: widget WILL re-render when onSuccess changes.
<Turnstile
siteKey="{{ siteKey }}"
rerenderOnCallbackChange={true}
onSuccess={currentCallback}
/>The @marsidev/react-turnstile library does not include built-in server-side validation. It only provides TypeScript types for the response.
To validate a token, you must implement your own server-side logic that calls the Cloudflare siteverify endpoint using your secret key.
Tip: Use Cloudflare's provided test site keys and secret keys during development to ensure your validation logic works without solving real CAPTCHAs.
If your application requires more than one Turnstile instance on a single page, follow these rules to prevent conflicts:
id to every Turnstile component. Using the same ID for multiple widgets will cause critical errors.You can track and respond to the lifecycle of the Turnstile widget by using the onError, onExpire, and onSuccess callback props. These allow you to update your application state based on whether the widget encountered an error, the challenge expired, or the user successfully solved the challenge.
import { Turnstile } from "@marsidev/react-turnstile";
import React from "react";
type Status = "error" | "expired" | "solved";
export default function Widget() {
const [status, setStatus] = React.useState<Status | null>(null);
return (
<Turnstile
siteKey="{{ siteKey }}"
onError={() => setStatus("error")}
onExpire={() => setStatus("expired")}
onSuccess={() => setStatus("solved")}
/>
);
}To integrate Cloudflare Turnstile, install the @marsidev/react-turnstile package and use the Turnstile component. You must provide a siteKey prop.
Important Configuration Note:
Do not use size="invisible" with a normal widget type; ensure the size and widget type are compatible to avoid configuration errors.
Turnstile tokens are single-use; once validated on the server, they are consumed. To prevent user friction when form validation fails (e.g., incorrect password), you can implement a client-side retry mechanism. This allows a user to attempt multiple form submissions using the same validated token before requiring a new Turnstile challenge.
triesLeft) to track how many submissions are allowed with the current token./api/verify endpoint if triesLeft is 0. If triesLeft > 0, decrement the counter and proceed directly to the form action.turnstileRef.current?.reset() method to force a new challenge.onExpire callback to reset your local triesLeft counter to 0, ensuring the next submission triggers a fresh token validation.Note: The retry counter is a client-side UX optimization only. You must always perform actual token validation on the server.
"use client";
import { useRef, useState } from "react";
import { Turnstile } from "@marsidev/react-turnstile";
import type { TurnstileInstance } from "@marsidev/react-turnstile";
const MAX_TRIES = 5;
export default function LoginForm() {
const turnstileRef = useRef<TurnstileInstance | null>(null);
const [triesLeft, setTriesLeft] = useState(0);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
if (triesLeft === 0) {
// 1. Validate the token on the server
const res = await fetch("/api/verify", {
method: "POST",
body: JSON.stringify({ token: captchaToken }),
headers: { "content-type": "application/json" },
});
const data = await res.json();
if (!data.success) {
setError("Captcha validation failed. Please try again.");
turnstileRef.current?.reset(); // Reset widget on validation failure
return;
}
// 2. Set remaining tries after successful token validation
setTriesLeft(MAX_TRIES - 1);
setError(null);
} else {
// 3. Use existing token and decrement tries
setTriesLeft((prev) => prev - 1);
}
// 4. Proceed with the actual form submission
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({
username: formData.get("username"),
password: formData.get("password"),
}),
headers: { "content-type": "application/json" },
});
const data = (await res.json()) as { success: boolean; message?: string };
if (!data.success) {
setError(data.message ?? "Login failed. Please try again.");
}
} catch {
setError("An unexpected error occurred. Please try again.");
turnstileRef.current?.reset();
}
}
return (
<form onSubmit={handleSubmit}>
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
{error && <p>{error}</p>}
<Turnstile
ref={turnstileRef}
siteKey="{{ siteKey }}"
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => {
setCaptchaToken(null);
setTriesLeft(0);
}}
/>
<button type="submit">Login</button>
</form>
);
}You can capture the Turnstile response token automatically by providing a callback function to the onSuccess prop of the Turnstile component. This is useful for updating local state with the token as soon as the verification is successful.
import { Turnstile } from "@marsidev/react-turnstile";
export default function Widget() {
const [token, setToken] = React.useState<string | null>(null);
return (
<Turnstile siteKey="{{ siteKey }}" onSuccess={setToken} />
);
}Token validation must be performed on your server to ensure security. The process involves two steps:
FormData under the key cf-turnstile-response.https://challenges.cloudflare.com/turnstile/v0/siteverify) using a POST request. The body must be application/x-www-form-urlencoded containing your secret and the response (the token).Use the TurnstileServerValidationResponse type to type the response from Cloudflare.
// 1. Client-side: Extract token from form
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(formRef.current!);
const token = formData.get("cf-turnstile-response");
const res = await fetch("/api/verify", {
method: "POST",
body: JSON.stringify({ token }),
headers: {
"content-type": "application/json",
},
});
// ... handle response
}
// 2. Server-side: Verify with Cloudflare
export async function POST(request: Request) {
const { token } = (await request.json()) as { token: string };
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: `secret=${encodeURIComponent(secret)}&response=${encodeURIComponent(token)}`,
headers: {
"content-type": "application/x-www-form-urlencoded",
},
});
const data = (await res.json()) as TurnstileServerValidationResponse;
return new Response(JSON.stringify(data), {
status: data.success ? 200 : 400,
headers: { "content-type": "application/json" },
});
}When using Turnstile inside a standard HTML <form>, the component automatically populates a hidden input field with the name cf-turnstile-response. You can retrieve this token during form submission using the FormData API.
import { Turnstile } from "@marsidev/react-turnstile";
export default function Widget() {
const formRef = React.useRef<HTMLFormElement>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(formRef.current!);
const token = formData.get("cf-turnstile-response");
// ...
}
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input type="text" placeholder="username" />
<input type="password" placeholder="password" />
<Turnstile siteKey="{{ siteKey }}" />
<button type="submit">Login</button>
</form>
);
}By default, the Turnstile widget handles its own expiration. However, if you set options.refreshExpired to 'manual' or 'never', you are responsible for refreshing the widget when it expires.
To automatically reset the widget when it expires, use the onExpire callback provided by the Turnstile component and call the .reset() method on the instance via a ref of type TurnstileInstance.
import { Turnstile } from "@marsidev/react-turnstile";
import type { TurnstileInstance } from "@marsidev/react-turnstile";
export default function Widget() {
const ref = React.useRef<TurnstileInstance | null>(null);
return (
<Turnstile
ref={ref}
options={{ refreshExpired: "manual" }}
siteKey="{{ siteKey }}"
onExpire={() => ref.current?.reset()}
/>
);
}