react-turnstile

repository·main·Indexed 21 days ago

https://github.com/marsidev/react-turnstile

A 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().

Tokens
24.9K
Snippets
57
Records
83
Agent score
65%

What's inside react-turnstile

  1. Handle Token Lifecycle and Form Integration

    main

    Managing the Turnstile token is critical for successful form submissions:

    • Token Expiration: Tokens are single-use and expire. Ensure you handle token expiration and reset the widget if the token expires before the form is submitted.
    • Imperative API: You can use imperative methods to control the widget, but do not call these methods before the widget has fully loaded.
    • Callback Stability: If you use rerenderOnCallbackChange={true}, wrap your callback functions in useCallback to prevent infinite re-render loops caused by stale closures or changing references.
  2. How dynamic callbacks work in Turnstile

    main

    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} 
    />
  3. Implement Server-Side Validation

    main

    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.

  4. Manage Multiple Turnstile Widgets

    main

    If your application requires more than one Turnstile instance on a single page, follow these rules to prevent conflicts:

    1. Unique IDs: You must assign a unique id to every Turnstile component. Using the same ID for multiple widgets will cause critical errors.
    2. Race Conditions: Be mindful of script loading race conditions when multiple widgets are initialized simultaneously.
  5. Manage widget status using callbacks

    main

    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")}
        />
      );
    }
  6. Implement form submission retry logic with Turnstile

    main

    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.

    Implementation Strategy

    1. Track Tries: Use a state variable (e.g., triesLeft) to track how many submissions are allowed with the current token.
    2. Conditional Validation: On form submission, only call your server-side /api/verify endpoint if triesLeft is 0. If triesLeft > 0, decrement the counter and proceed directly to the form action.
    3. Resetting the Widget: If the Turnstile token itself fails validation or expires, use the turnstileRef.current?.reset() method to force a new challenge.
    4. Handling Expiration: Use the 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>
      );
    }
  7. Get widget token using the `onSuccess` callback

    main

    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} />
      );
    }
  8. Validate a Turnstile token on the server

    main

    Token validation must be performed on your server to ensure security. The process involves two steps:

    1. Client-side: Extract the token from the form submission. The token is available in the FormData under the key cf-turnstile-response.
    2. Server-side: Send the token to the Cloudflare verification endpoint (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" },
      });
    }
  9. Get widget token via the `cf-turnstile-response` HTML input

    main

    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>
      );
    }
  10. Handle Turnstile widget expiration manually

    main

    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()}
        />
      );
    }