---
title: "Bot Protection in Astro with Cloudflare Turnstile"
description: "Replace CAPTCHA with invisible bot detection in Astro using Cloudflare Turnstile. Protect sign-up forms, login pages, and API endpoints from bots without frustrating real users."
source: "https://www.launchfa.st/blog/astro-turnstile-bot-protection"
author: "Rishi Raj Jain"
created_at: 2025-12-09T12:00:00.000Z
keywords: "astro, cloudflare turnstile, bot protection, captcha alternative, form security, spam prevention"
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Bot Protection in Astro with Cloudflare Turnstile" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/astro-turnstile-bot-protection.png" />

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](https://developers.cloudflare.com/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.

- [View example project on GitHub](https://github.com/rishi-raj-jain/my-astro-turnstile-app)

## Prerequisites

You'll need:

- [Node.js 20](https://nodejs.org/en/blog/announcements/v20-release-announce) or later
- A [Cloudflare](https://dash.cloudflare.com/sign-up) 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 + metadata
```

## Get Turnstile API keys

Before building, you need Turnstile API keys:

1. Go to the [Cloudflare dashboard](https://dash.cloudflare.com/)
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 (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:

```bash
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:

```bash
npm run dev
```

The app should be running on [localhost:4321](http://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:

```bash
npx astro add cloudflare --yes
```

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

```javascript
// File: 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`:

```jsonc
// File: 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:

```bash
# 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:

```bash
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:

```typescript
// File: 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`:

```typescript
// File: 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:

```typescript
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`:

```typescript
// File: 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:

```astro
---
// File: 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

```html
<!-- 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

```html
<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](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes-reference):

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

```typescript
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:

```bash
# 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](/blog/rate-limiting-astro-cloudflare-workers) 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, 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](https://twitter.com/direct_messages/create/rishi_raj_jain_).
