Bot Protection in Astro with Cloudflare Turnstile
LaunchFast Logo LaunchFast

Bot Protection in Astro with Cloudflare Turnstile

Rishi Raj Jain
Bot Protection in Astro with Cloudflare Turnstile

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.

High Quality Starter Kits with built-in authentication flow (Auth.js), object uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrated payments (Stripe, LemonSqueezy), email verification flow (Resend, Postmark, Sendgrid), and much more. Compatible with any database (Redis, Postgres, MongoDB, SQLite, Firestore).
Next.js Starter Kit
SvelteKit Starter Kit

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:

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 + metadata

Get Turnstile API keys

Before building, you need Turnstile API keys:

  1. Go to the Cloudflare dashboard
  2. Navigate to Application security > Turnstile in the sidebar
  3. Click Add site
  4. 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)
  5. 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:

Terminal window
npm create astro@latest my-astro-turnstile-app
cd my-astro-turnstile-app
npm install wrangler

When prompted, choose:

  • Use minimal (empty) template when prompted on how to start the new project.
  • Yes when prompted to install dependencies.
  • Yes when prompted to initialize a git repository.

Once that’s done, you can move into the project directory and start the app:

Terminal window
npm run dev

The 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:

Terminal window
npx astro add cloudflare --yes

Update astro.config.mjs to have output as server:

astro.config.mjs
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:

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.vars file:

.dev.vars
TURNSTILE_SITE_KEY="your-site-key"
TURNSTILE_SECRET_KEY="your-secret-key"

For production, set the secret via Cloudflare dashboard or CLI:

Terminal window
npx wrangler secret put TURNSTILE_SITE_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY

Add TypeScript types

Create src/env.d.ts to add proper types for environment variables:

src/env.d.ts
/// <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:

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:

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:

src/pages/contact.astro
---
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 CodeMeaningSolution
missing-input-secretSecret key not providedCheck environment variables
invalid-input-secretInvalid secret keyVerify your secret key
missing-input-responseToken not providedEnsure form submits token
invalid-input-responseInvalid or expired tokenAsk user to retry verification
timeout-or-duplicateToken already used or timed outGenerate 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:

Terminal window
# Build the project
npm run build
# Deploy to Cloudflare Workers
npx wrangler deploy

Your 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.

Learn More Live Collaborative Editing in Astro with Cloudflare Durable Objects
Live Collaborative Editing in Astro with Cloudflare Durable Objects December 9, 2025
Generating PDFs in Astro with Cloudflare Browser Rendering at the Edge
Generating PDFs in Astro with Cloudflare Browser Rendering at the Edge December 8, 2025
Implement Rate Limiting in Astro with Cloudflare Workers
Implement Rate Limiting in Astro with Cloudflare Workers December 6, 2025