Turnstile Plugin (Bot Protection)

Protect your forms and APIs from bots using Cloudflare Turnstile - a privacy-friendly, CAPTCHA-free alternative that verifies users without annoying puzzles.


Overview

Turnstile is Cloudflare's smart CAPTCHA alternative. Unlike traditional CAPTCHAs that interrupt users with puzzles, Turnstile runs silently in the background and only challenges users when suspicious behavior is detected.

🛡️

Bot Protection

Blocks automated attacks, scrapers, and spam bots

🔒

Privacy-First

No tracking, no cookies, GDPR compliant

Invisible Mode

Verify users without showing any widget

🎨

Customizable

Multiple themes, sizes, and interaction modes

Use Cases

  • Contact forms - Prevent spam submissions
  • Registration - Block automated account creation
  • Login forms - Protect against credential stuffing
  • API endpoints - Rate limit with verification
  • Comments - Stop spam comments on blogs

Setup

Step 1: Get Turnstile Keys

  1. Log in to Cloudflare Dashboard
  2. Navigate to Turnstile in the sidebar
  3. Click Add Site
  4. Enter your domain and select widget type
  5. Copy your Site Key and Secret Key

Step 2: Configure in Admin

  1. Go to Admin > Plugins > Turnstile
  2. Enter your Site Key and Secret Key
  3. Configure widget options
  4. Enable the plugin
  5. Save settings

Step 3: Add Widget to Forms

Add the Turnstile widget to any form you want to protect:

Basic Integration

<form action="/api/contact" method="POST">
  <input type="text" name="name" placeholder="Name" />
  <input type="email" name="email" placeholder="Email" />
  <textarea name="message" placeholder="Message"></textarea>

  <!-- Turnstile widget -->
  <div
    class="cf-turnstile"
    data-sitekey="YOUR_SITE_KEY"
    data-theme="auto"
  ></div>

  <button type="submit">Send</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Configuration

Plugin Settings

interface TurnstileSettings {
  siteKey: string           // Public site key (shown in widget)
  secretKey: string         // Secret key (server-side verification)
  theme?: 'light' | 'dark' | 'auto'  // Widget theme
  size?: 'normal' | 'compact'        // Widget size
  mode?: 'managed' | 'non-interactive' | 'invisible'
  appearance?: 'always' | 'execute' | 'interaction-only'
  enabled: boolean          // Enable/disable verification
}

Widget Modes

ModeDescriptionBest For
managedCloudflare decides when to show challengeGeneral use, recommended
non-interactiveNever shows interactive challengeLow-risk forms
invisibleCompletely hidden, automatic verificationSeamless UX

Appearance Options

AppearanceDescription
alwaysAlways show the widget
executeShow only when verification runs
interaction-onlyShow only if user interaction needed

Theme Options

  • light - Light background theme
  • dark - Dark background theme
  • auto - Match user's system preference

Widget Integration

Using SonicJS Helpers

SonicJS provides helper functions to render Turnstile widgets:

Widget Helpers

import {
  renderTurnstileWidget,
  renderInlineTurnstile,
  renderExplicitTurnstile,
  getTurnstileScript
} from '@sonicjs-cms/core/plugins'

// Full widget with settings from database
const widget = renderTurnstileWidget(settings, 'my-form-turnstile')

// Simple inline widget
const inline = renderInlineTurnstile('YOUR_SITE_KEY', {
  theme: 'dark',
  size: 'compact'
})

// Explicit rendering (more control)
const explicit = renderExplicitTurnstile('YOUR_SITE_KEY', {
  callback: 'onVerificationComplete'
})

// Just the script tag
const script = getTurnstileScript()

Full Widget Example

renderTurnstileWidget

// In your route handler
import { renderTurnstileWidget } from '@sonicjs-cms/core/plugins'
import { TurnstileService } from '@sonicjs-cms/core/plugins'

app.get('/contact', async (c) => {
  const turnstile = new TurnstileService(c.env.DB)
  const settings = await turnstile.getSettings()

  const widgetHtml = settings ? renderTurnstileWidget(settings) : ''

  return c.html(`
    <form method="POST" action="/api/contact">
      <input name="email" type="email" required />
      <textarea name="message" required></textarea>
      ${widgetHtml}
      <button type="submit">Send</button>
    </form>
  `)
})

Inline Widget

For simple cases where you just need a widget with a site key:

renderInlineTurnstile

import { renderInlineTurnstile } from '@sonicjs-cms/core/plugins'

const widget = renderInlineTurnstile('YOUR_SITE_KEY', {
  theme: 'auto',
  size: 'normal',
  containerId: 'contact-turnstile'
})

// Output:
// <div id="contact-turnstile" class="cf-turnstile mb-3"
//      data-sitekey="YOUR_SITE_KEY"
//      data-theme="auto"
//      data-size="normal"></div>
// <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Explicit Rendering

For full control over when the widget renders:

renderExplicitTurnstile

import { renderExplicitTurnstile } from '@sonicjs-cms/core/plugins'

const widget = renderExplicitTurnstile('YOUR_SITE_KEY', {
  theme: 'dark',
  callback: 'handleTurnstileSuccess'
})

// In your page:
// <script>
// function handleTurnstileSuccess(token) {
//   console.log('Verified:', token)
//   document.getElementById('submitBtn').disabled = false
// }
// </script>

Server Verification

Using the Service

Verify tokens server-side before processing form submissions:

Manual Verification

import { TurnstileService } from '@sonicjs-cms/core/plugins'

app.post('/api/contact', async (c) => {
  const body = await c.req.parseBody()
  const token = body['cf-turnstile-response'] as string

  // Initialize service
  const turnstile = new TurnstileService(c.env.DB)

  // Verify token
  const result = await turnstile.verifyToken(
    token,
    c.req.header('CF-Connecting-IP') // Optional: pass user IP
  )

  if (!result.success) {
    return c.json({ error: result.error || 'Verification failed' }, 403)
  }

  // Token valid - process form
  // ...

  return c.json({ success: true })
})

Verification Response

Response Types

// Success response
{
  success: true
}

// Failure response
{
  success: false,
  error: 'Turnstile verification failed: invalid-input-response'
}

// Not configured
{
  success: false,
  error: 'Turnstile not configured'
}

// Disabled (passes through)
{
  success: true  // When plugin is disabled, verification passes
}

Middleware

Built-in Middleware

Use the verifyTurnstile middleware to automatically verify requests:

Middleware Usage

import { verifyTurnstile } from '@sonicjs-cms/core/plugins'
import { Hono } from 'hono'

const app = new Hono()

// Protect a route with Turnstile
app.post('/api/contact', verifyTurnstile, async (c) => {
  // Only reaches here if Turnstile verification passed
  const body = await c.req.parseBody()

  // Process form...

  return c.json({ success: true })
})

// Protect multiple routes
const protectedRoutes = new Hono()
protectedRoutes.use('*', verifyTurnstile)
protectedRoutes.post('/submit', async (c) => { /* ... */ })
protectedRoutes.post('/register', async (c) => { /* ... */ })

app.route('/api/protected', protectedRoutes)

Custom Middleware

Create custom middleware with additional options:

Custom Middleware

import { createTurnstileMiddleware } from '@sonicjs-cms/core/plugins'

// Custom middleware with options
const customTurnstile = createTurnstileMiddleware({
  errorResponse: (c, error) => {
    return c.html(`
      <div class="error">
        Verification failed: ${error}
        <a href="javascript:history.back()">Go back</a>
      </div>
    `)
  },
  skipRoutes: ['/api/health', '/api/public']
})

app.use('/api/*', customTurnstile)

Plugin Integration

When building plugins, use the middleware factory:

Plugin Usage

import { PluginBuilder } from '@sonicjs-cms/core'
import { verifyTurnstile } from '@sonicjs-cms/core/plugins'

const myPlugin = new PluginBuilder({
  name: 'my-plugin',
  version: '1.0.0'
})
  .addRoute('/api/my-plugin/submit', submitRoutes)
  .addMiddleware('turnstile', verifyTurnstile, {
    description: 'Verify bot protection',
    routes: ['/api/my-plugin/submit']
  })
  .build()

Advanced Usage

Conditional Verification

Skip verification for authenticated users:

Conditional Verification

app.post('/api/comment', async (c, next) => {
  const user = c.get('user')

  // Skip Turnstile for logged-in users
  if (user) {
    return next()
  }

  // Verify Turnstile for anonymous users
  const turnstile = new TurnstileService(c.env.DB)
  const token = (await c.req.parseBody())['cf-turnstile-response'] as string

  const result = await turnstile.verifyToken(token)
  if (!result.success) {
    return c.json({ error: 'Verification required' }, 403)
  }

  return next()
})

JavaScript Callback Handling

Handle verification events in JavaScript:

JavaScript 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 successfully')
  document.getElementById('submitBtn').disabled = false

  // Optionally store token for AJAX submission
  document.getElementById('turnstileToken').value = token
}

function onTurnstileError(error) {
  console.error('Turnstile error:', error)
  alert('Verification failed. Please refresh and try again.')
}

function onTurnstileExpired() {
  console.warn('Turnstile token expired')
  document.getElementById('submitBtn').disabled = true
  // Optionally trigger refresh
  turnstile.reset()
}
</script>

AJAX Form Submission

Submit forms with Turnstile via AJAX:

AJAX Integration

document.getElementById('contactForm').addEventListener('submit', async (e) => {
  e.preventDefault()

  const formData = new FormData(e.target)

  // Token is automatically included in FormData from the widget
  const response = await fetch('/api/contact', {
    method: 'POST',
    body: formData
  })

  const result = await response.json()

  if (result.success) {
    alert('Message sent!')
    e.target.reset()
    turnstile.reset() // Reset widget for next submission
  } else {
    alert(result.error || 'Submission failed')
  }
})

Pre-Clearance Mode

For high-traffic sites, use pre-clearance to reduce verification overhead:

Pre-Clearance

// In plugin settings
{
  preClearance: true,
  preClearanceLevel: 'managed'  // or 'interactive', 'non-interactive'
}

Pre-clearance levels:

  • interactive - Always show challenge first
  • managed - Let Cloudflare decide
  • non-interactive - Never show initial challenge

Troubleshooting

Widget Not Appearing

Check site key:

  • Ensure site key is correct and matches your domain
  • Verify the domain is added to your Turnstile site

Check script loading:

<!-- Ensure script is loaded -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Check container:

<!-- Ensure container has correct class or data-sitekey -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

Verification Failing

Check secret key:

  • Ensure secret key is configured in admin settings
  • Verify it matches the site key's secret

Check token:

  • Token is in cf-turnstile-response form field
  • Token hasn't expired (valid for ~300 seconds)

Check IP:

  • Optional but helps with accuracy
  • Pass CF-Connecting-IP header

Testing Locally

For local development, Cloudflare provides test keys:

Test Keys

// Test keys that always pass
const TEST_SITE_KEY = '1x00000000000000000000AA'
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'

// Test keys that always fail
const FAIL_SITE_KEY = '2x00000000000000000000AB'
const FAIL_SECRET_KEY = '2x0000000000000000000000000000000AB'

// Test keys that force interactive challenge
const CHALLENGE_SITE_KEY = '3x00000000000000000000FF'

Error Codes

CodeDescription
missing-input-secretSecret key not provided
invalid-input-secretSecret key is malformed
missing-input-responseToken not provided
invalid-input-responseToken is malformed or expired
bad-requestRequest was malformed
timeout-or-duplicateToken already used or timed out
internal-errorCloudflare internal error

Next Steps

Was this page helpful?