Integrate Cloudflare Turnstile into Astro and React Apps
/ 6 min read
Form submission interfaces are a critical vector for engagement in any modern web application. Yet, they simultaneously represent a persistent challenge: mitigating the deluge of spam and automated submissions without degrading the UX.
While initial defense layers—such as schema validation, custom server-side validators, and rigorous input sanitization—are fundamental, an additional, robust layer of bot defense is essential.
This guide details the strategic implementation of Cloudflare Turnstile, the privacy-preserving successor to traditional CAPTCHA, integrated within a type-safe Astro and React environment using server-side validation.
🛡️ Cloudflare Turnstile: Redefining Human Verification
Cloudflare Turnstile represents a pivotal shift away from the intrusive visual puzzles of conventional CAPTCHA systems. It is engineered as a privacy-first, non-interactive validation service designed to seamlessly distinguish between legitimate human users and malicious bots.
Instead of demanding user interaction (like solving a puzzle or clicking a checkbox), Turnstile leverages a dynamic suite of non-interactive, in-browser challenges. These challenges rapidly analyze the visitor’s environment and behavior patterns.
🛠️ Project Initialization and Key Management
We initiate our project using the latest Astro CLI, providing a robust environment for static and dynamic rendering:
pnpm create astro@latestSecuring Cloudflare Credentials
To enable verification, we must provision the necessary credentials from the Cloudflare Turnstile dashboard.
First, you need to get your site keys from Cloudflare.
-
Go to the Cloudflare Turnstile dashboard.
-
Click “Add Site”.
-
Fill in your Site Name and Domain (e.g., localhost for development and your production domain).
-
Choose the Widget Mode. “Managed” is recommended for the best user experience.
-
Click “Create”.
You will now see your Site Key and Secret Key.
Site Key: Public, and safe to include in your client-side code.
Secret Key: Private. Must never be exposed to the frontend. We’ll use this on our server.
Environment Configuration
We must secure the private key using environment variables. Create a .env file at the root of your project:
TURNSTILE_SITE_KEY=your_turnstile_site_key_hereTURNSTILE_SECRET_KEY=your_turnstile_secret_key_hereCrucially, you must add .env to your .gitignore file to prevent committing your secret key to version control.
Now, we need to create an API endpoint in Astro to receive the form submission and validate the Turnstile token.
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export async function verifyTurnstileToken(token: string, secretKey: string): Promise<{ success: boolean; error?: string;}> { try { if (!secretKey) { console.error('TURNSTILE_SECRET_KEY is not configured'); return { success: false, error: 'Server configuration error.', }; }
const response = await fetch(TURNSTILE_VERIFY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ secret: secretKey, response: token, }), });
if (!response.ok) { return { success: false, error: 'Turnstile service unavailable', }; }
const result = await response.json();
if (!result.success) { console.error('Turnstile verification failed:', result.error_codes); return { success: false, error: 'Verification failed. Please try again.', }; }
return { success: true, }; } catch (error) { console.error('Turnstile verification error:', error); return { success: false, error: 'Failed to verify token', }; }}We created a function which receives two parameters of token and secret key.
This functioin will verify that the token we pass to turnstile widget is valid.
Creating an API with schema validation
We now construct the Astro API endpoint, which handles the entire submission lifecycle: data retrieval, schema validation (using Valibot), and Turnstile verification.
import { getSecret } from 'astro:env/server';import { validateContactForm, verifyTurnstileToken } from '@/utils/index';import type { ContactFormData, FormState } from '@/types/contact';import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => { const TURNSTILE_SECRET_KEY = getSecret('TURNSTILE_SECRET_KEY');
try { const formData = await request.formData();
const data = { name: formData.get('name'), email: formData.get('email'), message: formData.get('message'), 'cf-turnstile-response': formData.get('cf-turnstile-response'), };
const validationResult = validateContactForm(data); if (!validationResult.success) { return new Response( JSON.stringify({ success: false, error: validationResult.error, } as FormState), { status: 400, headers: { 'Content-Type': 'application/json' } } ); }
const validatedData = validationResult.data!;
const turnstileResult = await verifyTurnstileToken( validatedData['cf-turnstile-response'], TURNSTILE_SECRET_KEY as string );
if (!turnstileResult.success) { return new Response( JSON.stringify({ success: false, error: turnstileResult.error || 'Turnstile verification failed', } as FormState), { status: 400, headers: { 'Content-Type': 'application/json' } } ); }
await handleContactFormSubmission(validatedData);
return new Response( JSON.stringify({ success: true, message: 'Thank you for your message! We will get back to you soon.', } as FormState), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('Form submission error:', error); return new Response( JSON.stringify({ success: false, error: 'An error occurred. Please try again later.', } as FormState), { status: 500, headers: { 'Content-Type': 'application/json' } } ); }};
async function handleContactFormSubmission(data: ContactFormData): Promise<void> { console.log('Form submission data:', { name: data.name, email: data.email, message: data.message, timestamp: new Date().toISOString(), });}Creating client component
The front-end is managed by a React component, rendered client-side in Astro using the client:load directive. We use the useActionState hook to manage the form’s asynchronous submission state.
---export const prerender = false
import ContactForm from '@/components/Contactform';import Layout from '@/layouts/Layout.astro';
---
<Layout> <div class="min-h-screen flex items-center justify-center px-4"> <div class="w-full max-w-xl bg-white shadow-md rounded-2xl p-8"> <h1 class="text-3xl font-semibold mb-6 text-gray-800">Contact Us</h1> <ContactForm client:load /> </div> </div></Layout>So, inside of our component we will load turnstile widget and use it along side with other form fields.
import { useRef, useEffect, useActionState } from "react";import type { FormState } from "@/types/contact";import { submitAction } from "@/actions/contact";
export default function ContactForm() { const formRef = useRef<HTMLFormElement>(null);
const [formState, formAction, isPending] = useActionState<FormState>( submitAction as any, { message: "" } );
// Load Turnstile script useEffect(() => { const script = document.createElement("script"); script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; script.async = true; document.head.appendChild(script);
return () => { const s = document.querySelector( 'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js"]' ); if (s) document.head.removeChild(s); }; }, []);
return ( <form ref={formRef} action={formAction} className="flex flex-col gap-4" >
{/* All form fields goes here */}
{/* Turnstile */} <div className="cf-turnstile w-full" data-sitekey="0x4AAAAAACDAp2pSvhjq3_Wm" data-size="flexible" />
{/* Error */} {formState?.error && ( <div className="p-3 bg-red-100 text-red-800 rounded-md"> {formState.error} </div> )}
{/* Success */} {formState?.success && ( <div className="p-3 bg-green-100 text-green-800 rounded-md"> {formState.message} </div> )}
<button type="submit" disabled={isPending} className={`p-3 px-6 bg-blue-600 text-white rounded-md text-base font-semibold hover:bg-blue-700 transition ${isPending ? "opacity-60 cursor-not-allowed" : ""}`} > {isPending ? "Submitting…" : "Submit"} </button> </form> );}In the code above we used a modern hook available to us useActionState. This helps us to maintain the state of form like loading, submitted and current state. Its really handy to deal with forms with this new hook.
Conclusion
And that’s it! You’ve successfully fortified your Astro and React form against bots using Cloudflare Turnstile. This setup provides a robust security layer without compromising user experience. Say goodbye to confusing CAPTCHAs and hello to a cleaner, safer form.
Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I’ll do my best to get back to you.
You can find the complete source code in Github Link here.
Thank you!