Traditional CAPTCHAs protect forms from bots, but they also frustrate legitimate users with puzzles, image selection, and poor accessibility. Google reCAPTCHA v2 is notorious for making users click traffic lights until they rage-quit. Even “invisible” CAPTCHAs often fail silently or degrade user experience.
Cloudflare Turnstile replaces CAPTCHAs with smart, privacy-first bot detection. It runs in the background, analyzing browser behavior and device fingerprints without asking users to solve puzzles. Most legitimate users never see a challenge. When it does show up, it’s a simple checkbox (not an image gallery of fire hydrants).
In this guide, you’ll integrate Turnstile into an Astro application to protect sign-up forms, contact forms, and API endpoints. You’ll add the widget to your forms, verify tokens server-side, and handle edge cases like rate limiting and error states. By the end, you’ll have production-ready bot protection that scales globally on Cloudflare’s network.
Prerequisites
You’ll need:
- Node.js 20 or later
- A Cloudflare account
Architecture overview
1. Frontend (Astro component with Turnstile widget) → Renders Turnstile widget on form → Captures token when user completes challenge
2. Backend (API route or form handler) → Receives form data + Turnstile token → Verifies token with Cloudflare API → Processes request only if verification succeeds
3. Cloudflare Turnstile API → Validates token → Returns success/failure + metadataGet Turnstile API keys
Before building, you need Turnstile API keys:
- Go to the Cloudflare dashboard
- Navigate to Application security > Turnstile in the sidebar
- Click Add site
- Configure your site:
- Site name:
my-astro-turnstile-app - Domain: Add your domain (for e.g. workers.dev)
- Widget Mode: Choose Managed (recommended for most use cases)
- Site name:
- Click Create
You’ll receive:
- Site Key (public, used in frontend)
- Secret Key (private, used in backend)
Save these keys, you’ll need them in the next steps.
Create a new Astro application
Let’s get started by creating a new Astro project. Execute the following command:
npm create astro@latest my-astro-turnstile-appcd my-astro-turnstile-appnpm install wranglerWhen prompted, choose:
Use minimal (empty) templatewhen prompted on how to start the new project.Yeswhen prompted to install dependencies.Yeswhen prompted to initialize a git repository.
Once that’s done, you can move into the project directory and start the app:
npm run devThe app should be running on localhost:4321.
Integrate Cloudflare adapter in your Astro project
To deploy your Astro project to Cloudflare Workers and use environment variables securely, you need to install the Cloudflare adapter. Execute the command below:
npx astro add cloudflare --yesUpdate astro.config.mjs to have output as server:
import { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare()});Configure environment variables
Add your Turnstile keys to wrangler.jsonc:
{ "main": "dist/_worker.js/index.js", "name": "my-astro-turnstile-app", "compatibility_date": "2025-12-08", "compatibility_flags": [ "nodejs_compat", "global_fetch_strictly_public" ], "assets": { "binding": "ASSETS", "directory": "./dist" }, "observability": { "enabled": true }, "vars": { "TURNSTILE_SITE_KEY": "...", "TURNSTILE_SECRET_KEY": "..." }}Important: Never commit your secret key to version control. For local development, create a
.dev.varsfile:
TURNSTILE_SITE_KEY="your-site-key"TURNSTILE_SECRET_KEY="your-secret-key"For production, set the secret via Cloudflare dashboard or CLI:
npx wrangler secret put TURNSTILE_SITE_KEYnpx wrangler secret put TURNSTILE_SECRET_KEYAdd TypeScript types
Create src/env.d.ts to add proper types for environment variables:
/// <reference types="astro/client" />
type ENV = { TURNSTILE_SITE_KEY: string TURNSTILE_SECRET_KEY: string}
type Runtime = import('@astrojs/cloudflare').Runtime<ENV>
declare namespace App { interface Locals extends Runtime { }}This gives you full type safety when accessing locals.runtime.env.TURNSTILE_SITE_KEY and locals.runtime.env.TURNSTILE_SECRET_KEY in your routes.
Create a verification utility
For reusability, extract the Turnstile verification logic into a utility function. Create src/lib/turnstile.ts:
interface TurnstileResponse { success: boolean challenge_ts?: string hostname?: string 'error-codes'?: string[] action?: string cdata?: string}
/** * Verify a Turnstile token with Cloudflare's API */export async function verifyTurnstileToken( token: string, secretKey: string, remoteIP?: string): Promise<TurnstileResponse> { const response = await fetch( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ secret: secretKey, response: token, remoteip: remoteIP, // Optional: pass client IP for additional validation }), } )
if (!response.ok) { throw new Error(`Turnstile API error: ${response.status}`) }
return response.json()}
/** * Extract Turnstile token from form data or request body */export function getTurnstileToken(formData: FormData): string | null { return formData.get('cf-turnstile-response') as string | null}
/** * Helper to check if Turnstile verification is required */export function isTurnstileEnabled(env: any): boolean { return !!(env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY)}Now you can use this utility in any form handler or API route:
import { verifyTurnstileToken, getTurnstileToken } from '../lib/turnstile'
const token = getTurnstileToken(formData)if (!token) { return new Response('Missing verification token', { status: 400 })}
const result = await verifyTurnstileToken( token, Astro.locals.runtime.env.TURNSTILE_SECRET_KEY)
if (!result.success) { return new Response('Verification failed', { status: 403 })}
// Proceed with form processing...Protect API endpoints
You can also protect API endpoints with Turnstile. This is useful for preventing automated abuse of public APIs.
Create src/pages/api/contact.ts:
import type { APIRoute } from 'astro'import { verifyTurnstileToken } from '../../lib/turnstile'
export const POST: APIRoute = async ({ request, locals }) => { try { const body = await request.json() const { email, message, token } = body
// Validate required fields if (!email || !message) { return new Response( JSON.stringify({ error: 'Email and message are required' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ) }
// Verify Turnstile token if (!token) { return new Response( JSON.stringify({ error: 'Verification token missing' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ) }
const secretKey = locals.runtime.env.TURNSTILE_SECRET_KEY const clientIP = request.headers.get('CF-Connecting-IP') || undefined
const verification = await verifyTurnstileToken(token, secretKey, clientIP)
if (!verification.success) { return new Response( JSON.stringify({ error: 'Verification failed', codes: verification['error-codes'], }), { status: 403, headers: { 'Content-Type': 'application/json' } } ) }
// Process the contact form (e.g., send email, save to database) console.log('Valid contact form submission:', email)
return new Response( JSON.stringify({ success: true, message: 'Message received' }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) } catch (error) { console.error('Contact API error:', error) return new Response( JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ) }}Frontend form that calls this API:
---const siteKey = import.meta.env.PROD ? Astro.locals.runtime.env.TURNSTILE_SITE_KEY : import.meta.env.TURNSTILE_SITE_KEY;---
<html lang="en"> <head> <meta charset="UTF-8" /> <title>Contact Us</title> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> </head> <body> <h1>Contact Us</h1> <form id="contact-form"> <input type="email" name="email" placeholder="Your email" required /> <textarea name="message" placeholder="Your message" required></textarea> <div class="cf-turnstile" data-sitekey={siteKey}></div> <button type="submit">Send Message</button> </form> <script> const form = document.getElementById('contact-form'); form?.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(form); const token = formData.get('cf-turnstile-response'); const response = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, email: formData.get('email'), message: formData.get('message'), }), }); const result = await response.json(); if (result.success) { alert('Message sent successfully!'); form.reset(); } else alert('Error: ' + result.error); }); </script> </body></html>Widget customization
Turnstile offers several widget modes and themes:
Widget modes
<!-- Managed (recommended): Shows challenge only when needed --><div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<!-- Non-Interactive: Invisible, always runs in background --><div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-appearance="interaction-only"></div>Callbacks
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onTurnstileSuccess" data-error-callback="onTurnstileError" data-expired-callback="onTurnstileExpired"></div>
<script> function onTurnstileSuccess(token) { console.log('Turnstile verified:', token); }
function onTurnstileError(error) { console.error('Turnstile error:', error); }
function onTurnstileExpired() { console.warn('Turnstile token expired'); }</script>Error handling
Common Turnstile error codes:
| Error Code | Meaning | Solution |
|---|---|---|
missing-input-secret | Secret key not provided | Check environment variables |
invalid-input-secret | Invalid secret key | Verify your secret key |
missing-input-response | Token not provided | Ensure form submits token |
invalid-input-response | Invalid or expired token | Ask user to retry verification |
timeout-or-duplicate | Token already used or timed out | Generate a new token |
Handle errors gracefully:
const verification = await verifyTurnstileToken(token, secretKey)
if (!verification.success) { const errorCode = verification['error-codes']?.[0] switch (errorCode) { case 'timeout-or-duplicate': return new Response('Token expired. Please refresh the page.', { status: 400 }) case 'invalid-input-response': return new Response('Invalid verification. Please try again.', { status: 400 }) default: return new Response('Verification failed. Please contact support.', { status: 500 }) }}Deploy to Cloudflare Workers
Deploy your bot-protected application to production:
# Build the projectnpm run build
# Deploy to Cloudflare Workersnpx wrangler deployYour forms are now protected in production.
Best practices
- Always verify server-side: Never trust client-side validation alone.
- Use managed mode: Let Turnstile decide when to show challenges.
- Handle errors gracefully: Show user-friendly messages, not raw error codes.
- Combine with rate limiting: Add rate limiting for extra protection.
Conclusion
Cloudflare Turnstile replaces frustrating CAPTCHAs with invisible, privacy-first bot protection. By integrating Turnstile into your Astro forms and API endpoints, you block automated abuse without degrading the user experience for legitimate visitors.
The pattern you’ve built, i.e. client-side widget + server-side verification + graceful error handling easily scales to any form or API. Add it to login pages, checkout flows, comment sections, or any endpoint that needs protection from bots.
If you have any questions or comments, feel free to reach out to me on Twitter.