cloudflare-turnstile

Bot protection with Turnstile CAPTCHA alternative for forms, login, and signup flows. Supports implicit (auto-render) and explicit (programmatic) rendering modes, plus React integration via @marsidev/react-turnstile Mandatory server-side validation via Siteverify API; tokens expire in 5 minutes and are single-use only Prevents 15 documented issues including CSP blocking, Safari Hide IP failures, Chrome/Edge first-load errors, token reuse bugs, and Jest incompatibility Includes testing keys, error handling patterns, Hono/Next.js examples, and troubleshooting for error codes 100*/200*/300*/400*/600*

INSTALLATION
npx skills add https://github.com/jezweb/claude-skills --skill cloudflare-turnstile
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2a

# 1. Create widget: https://dash.cloudflare.com/?to=/:account/turnstile

#    Copy sitekey (public) and secret key (private)

# 2. Add widget to frontend

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

<form>

  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

  <button type="submit">Submit</button>

</form>

# 3. Validate token server-side (Cloudflare Workers)

const formData = await request.formData()

const token = formData.get('cf-turnstile-response')

const verifyFormData = new FormData()

verifyFormData.append('secret', env.TURNSTILE_SECRET_KEY)

verifyFormData.append('response', token)

verifyFormData.append('remoteip', request.headers.get('CF-Connecting-IP'))  // REQUIRED - see Critical Rules

const result = await fetch(

  'https://challenges.cloudflare.com/turnstile/v0/siteverify',

  { method: 'POST', body: verifyFormData }

)

const outcome = await result.json()

if (!outcome.success) return new Response('Invalid', { status: 401 })

CRITICAL:

  • Token expires in 5 minutes, single-use only
  • ALWAYS validate server-side (Siteverify API required)
  • Never proxy/cache api.js (must load from Cloudflare CDN)
  • Use different widgets for dev/staging/production

Rendering Modes

Implicit (auto-render on page load):

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

<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onSuccess"></div>

Explicit (programmatic control for SPAs):

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>

const widgetId = turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' })

turnstile.reset(widgetId)   // Reset widget

turnstile.getResponse(widgetId)  // Get token

React (using @marsidev/react-turnstile):

import { Turnstile } from '@marsidev/react-turnstile'

<Turnstile siteKey={TURNSTILE_SITE_KEY} onSuccess={setToken} />

Critical Rules

Always Do

Call Siteverify API - Server-side validation is mandatory

Use HTTPS - Never validate over HTTP

Protect secret keys - Never expose in frontend code

Handle token expiration - Tokens expire after 5 minutes

Implement error callbacks - Handle failures gracefully

Use dummy keys for testing - Test sitekey: 1x00000000000000000000AA

Set reasonable timeouts - Don't wait indefinitely for validation

Validate action/hostname - Check additional fields when specified

Rotate keys periodically - Use dashboard or API to rotate secrets

Monitor analytics - Track solve rates and failures

Always pass client IP to Siteverify - Use CF-Connecting-IP header (Workers) or X-Forwarded-For (Node.js). Cloudflare briefly enforced strict remoteip validation in Jan 2025, causing widespread failures for sites not passing correct IP

Never Do

Skip server validation - Client-side only = security vulnerability

Proxy api.js script - Must load from Cloudflare CDN

Reuse tokens - Each token is single-use only

Use GET requests - Siteverify only accepts POST

Expose secret key - Keep secrets in backend environment only

Trust client-side validation - Tokens can be forged

Cache api.js - Future updates will break your integration

Use production keys in tests - Use dummy keys instead

Ignore error callbacks - Always handle failures

Known Issues Prevention

This skill prevents 15 documented issues:

Issue #1: Missing Server-Side Validation

Error: Zero token validation in Turnstile Analytics dashboard

Source: https://developers.cloudflare.com/turnstile/get-started/

Why It Happens: Developers only implement client-side widget, skip Siteverify call

Prevention: All templates include mandatory server-side validation with Siteverify API

Issue #2: Token Expiration (5 Minutes)

Error: success: false for valid tokens submitted after delay

Source: https://developers.cloudflare.com/turnstile/get-started/server-side-validation

Why It Happens: Tokens expire 300 seconds after generation

Prevention: Templates document TTL and implement token refresh on expiration

Issue #3: Secret Key Exposed in Frontend

Error: Security bypass - attackers can validate their own tokens

Source: https://developers.cloudflare.com/turnstile/get-started/server-side-validation

Why It Happens: Secret key hardcoded in JavaScript or visible in source

Prevention: All templates show backend-only validation with environment variables

Issue #4: GET Request to Siteverify

Error: API returns 405 Method Not Allowed

Source: https://developers.cloudflare.com/turnstile/migration/recaptcha

Why It Happens: reCAPTCHA supports GET, Turnstile requires POST

Prevention: Templates use POST with FormData or JSON body

Issue #5: Content Security Policy Blocking

Error: Error 200500 - "Loading error: The iframe could not be loaded"

Source: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes

Why It Happens: CSP blocks challenges.cloudflare.com iframe

Prevention: Skill includes CSP configuration reference and check-csp.sh script

Issue #6: Widget Crash (Error 300030)

Error: Generic client execution error for legitimate users

Source: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903

Why It Happens: Unknown - appears to be Cloudflare-side issue (2025)

Prevention: Templates implement error callbacks, retry logic, and fallback handling

Issue #7: Configuration Error (Error 600010)

Error: Widget fails with "configuration error"

Source: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578

Why It Happens: Missing or deleted hostname in widget configuration

Prevention: Templates document hostname allowlist requirement and verification steps

Issue #8: Safari 18 / macOS 15 "Hide IP" Issue

Error: Error 300010 when Safari's "Hide IP address" is enabled

Source: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903

Why It Happens: Privacy settings interfere with challenge signals

Prevention: Error handling reference documents Safari workaround (disable Hide IP)

Issue #9: Brave Browser Confetti Animation Failure

Error: Verification fails during success animation

Source: https://github.com/brave/brave-browser/issues/45608 (April 2025)

Why It Happens: Brave shields block animation scripts

Prevention: Templates handle success before animation completes

Issue #10: Next.js + Jest Incompatibility

Error: @marsidev/react-turnstile breaks Jest tests

Source: https://github.com/marsidev/react-turnstile/issues/112 (Oct 2025)

Why It Happens: Module resolution issues with Jest

Prevention: Testing guide includes Jest mocking patterns and dummy sitekey usage

Issue #11: localhost Not in Allowlist

Error: Error 110200 - "Unknown domain: Domain not allowed"

Source: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes

Why It Happens: Production widget used in development without localhost in allowlist

Prevention: Templates use dummy test keys for dev, document localhost allowlist requirement

Issue #12: Token Reuse Attempt

Error: success: false with "token already spent" error

Source: https://developers.cloudflare.com/turnstile/troubleshooting/testing

Why It Happens: Each token can only be validated once. Turnstile tokens are single-use - after validation (success OR failure), the token is consumed and cannot be revalidated. Developers must explicitly call turnstile.reset() to generate a new token for subsequent submissions.

Prevention: Templates document single-use constraint and token refresh patterns

// CRITICAL: Reset widget after validation to get new token

const turnstileRef = useRef(null)

async function handleSubmit(e) {

  e.preventDefault()

  const token = formData.get('cf-turnstile-response')

  const result = await fetch('/api/submit', {

    method: 'POST',

    body: JSON.stringify({ token })

  })

  // Reset widget regardless of success/failure

  // Token is consumed either way

  if (turnstileRef.current) {

    turnstile.reset(turnstileRef.current)

  }

}

<Turnstile

  ref={turnstileRef}

  siteKey={TURNSTILE_SITE_KEY}

  onSuccess={setToken}

/>

Issue #13: Error 106010 - Chrome/Edge First-Load Failure

Error: 106010 - "Generic parameter error" on first widget load in Chrome/Edge browsers

Source: Cloudflare Error Codes, Community Report

Why It Happens: Unknown browser-specific issue affecting Chrome and Edge on first page load. Console shows 400 error to https://challenges.cloudflare.com/cdn-cgi/challenge-platform. Firefox is not affected. Subsequent page reloads work correctly.

Prevention: Implement error callback with auto-retry logic

turnstile.render('#container', {

  sitekey: SITE_KEY,

  retry: 'auto',

  'retry-interval': 8000,

  'error-callback': (errorCode) => {

    if (errorCode === '106010') {

      console.warn('Chrome/Edge first-load issue (106010), auto-retrying...')

      // Auto-retry will handle it

    }

  }

})

Workaround: Widget works correctly after page reload. Auto-retry setting resolves in most cases. Test in Incognito mode to rule out browser extensions. Review CSP rules to ensure Cloudflare Turnstile endpoints are allowed.

Issue #14: Multiple Widgets Visual Status Stuck (Community-sourced)

Error: Widget displays "Pending..." status even after successful token generation

Source: GitHub Issue #119

Why It Happens: CSS repaint issue when rendering multiple <Turnstile/> components on a single page. Only reproducible on full HD desktop screens. Token IS successfully generated (validation works), but visual status doesn't update. Hovering over widget triggers repaint and shows correct status.

Prevention: Force CSS repaint in success callback

<Turnstile

  siteKey={KEY}

  onSuccess={(token) => {

    setToken(token)

    // Force repaint by toggling display

    const widget = document.querySelector('.cf-turnstile')

    if (widget) {

      widget.style.display = 'none'

      setTimeout(() => widget.style.display = 'block', 0)

    }

  }}

/>

Note: This is a visual-only issue, not a validation failure. The token is correctly generated and functional.

Issue #15: Jest Compatibility with @marsidev/react-turnstile (Updated Dec 2025)

Error: Jest encountered an unexpected token when importing @marsidev/react-turnstile

Source: GitHub Issue #114, GitHub Issue #112

Why It Happens: ESM module resolution issues with Jest 30.2.0 (latest as of Dec 2025). Issue #112 closed as "not planned" by maintainer. Jest users are stuck; Vitest migration works.

Prevention: Mock the Turnstile component in Jest setup OR migrate to Vitest

// Option 1: Jest mocking (jest.setup.ts)

jest.mock('@marsidev/react-turnstile', () => ({

  Turnstile: () => <div data-testid="turnstile-mock" />,

}))

// Option 2: transformIgnorePatterns in jest.config.js

module.exports = {

  transformIgnorePatterns: [

    'node_modules/(?!(@marsidev/react-turnstile)/)'

  ]

}

// Option 3 (Recommended): Migrate to Vitest

// Vitest handles ESM modules correctly without mocking

Status: Maintainer closed issue as "not planned". Recommend migrating to Vitest for new projects.

Configuration

wrangler.jsonc:

{

  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },

  "secrets": ["TURNSTILE_SECRET_KEY"]  // Run: wrangler secret put TURNSTILE_SECRET_KEY

}

Required CSP:

<meta http-equiv="Content-Security-Policy" content="

  script-src 'self' https://challenges.cloudflare.com;

  frame-src 'self' https://challenges.cloudflare.com;

">

Common Patterns

Pattern 1: Hono + Cloudflare Workers

import { Hono } from 'hono'

type Bindings = {

  TURNSTILE_SECRET_KEY: string

  TURNSTILE_SITE_KEY: string

}

const app = new Hono<{ Bindings: Bindings }>()

app.post('/api/login', async (c) => {

  const body = await c.req.formData()

  const token = body.get('cf-turnstile-response')

  if (!token) {

    return c.text('Missing Turnstile token', 400)

  }

  // Validate token

  const verifyFormData = new FormData()

  verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)

  verifyFormData.append('response', token.toString())

  verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')  // CRITICAL - always pass client IP

  const verifyResult = await fetch(

    'https://challenges.cloudflare.com/turnstile/v0/siteverify',

    {

      method: 'POST',

      body: verifyFormData,

    }

  )

  const outcome = await verifyResult.json<{ success: boolean }>()

  if (!outcome.success) {

    return c.text('Invalid Turnstile token', 401)

  }

  // Process login

  return c.json({ message: 'Login successful' })

})

export default app

When to use: API routes in Cloudflare Workers with Hono framework

Pattern 2: React + Next.js App Router

'use client'

import { Turnstile } from '@marsidev/react-turnstile'

import { useState } from 'react'

export function ContactForm() {

  const [token, setToken] = useState<string>()

  const [error, setError] = useState<string>()

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {

    e.preventDefault()

    if (!token) {

      setError('Please complete the challenge')

      return

    }

    const formData = new FormData(e.currentTarget)

    formData.append('cf-turnstile-response', token)

    const response = await fetch('/api/contact', {

      method: 'POST',

      body: formData,

    })

    if (!response.ok) {

      setError('Submission failed')

      return

    }

    // Success

  }

  return (

    <form onSubmit={handleSubmit}>

      <input name="email" type="email" required />

      <textarea name="message" required />

      <Turnstile

        siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}

        onSuccess={setToken}

        onError={() => setError('Challenge failed')}

        onExpire={() => setToken(undefined)}

      />

      {error &#x26;&#x26; <div className="error">{error}</div>}

      <button type="submit" disabled={!token}>

        Submit

      </button>

    </form>

  )

}

When to use: Client-side forms in Next.js with React hooks

Testing Keys

Dummy Sitekeys (client):

  • Always pass: 1x00000000000000000000AA
  • Always block: 2x00000000000000000000AB
  • Force interactive: 3x00000000000000000000FF

Dummy Secret Keys (server):

  • Always pass: 1x0000000000000000000000000000000AA
  • Always fail: 2x0000000000000000000000000000000AA
  • Token already spent: 3x0000000000000000000000000000000AA

Bundled Resources

Scripts: check-csp.sh - Verify CSP allows Turnstile

References:

  • widget-configs.md - All configuration options
  • error-codes.md - Error code troubleshooting (100/200/300/400/600*)
  • testing-guide.md - Testing strategies, dummy keys
  • react-integration.md - React/Next.js patterns

Templates: Complete examples for Hono, React, implicit/explicit rendering, validation

Advanced Features

Pre-Clearance (SPAs): Issue cookie that persists across page navigations

turnstile.render('#container', {

  sitekey: SITE_KEY,

  callback: async (token) => {

    await fetch('/api/pre-clearance', { method: 'POST', body: JSON.stringify({ token }) })

  }

})

Custom Actions &#x26; Data: Track challenge types, pass custom data (max 255 chars)

turnstile.render('#container', {

  action: 'login',  // Track in analytics

  cdata: JSON.stringify({ userId: '123' }),  // Custom payload

})

Error Handling: Use retry: 'auto' and error-callback for resilience

turnstile.render('#container', {

  retry: 'auto',

  'retry-interval': 8000,  // ms between retries

  'error-callback': (error) => { /* handle or show fallback */ }

})

Dependencies

Required: None (loads from CDN)

React: @marsidev/react-turnstile@1.4.1 (Cloudflare-recommended), turnstile-types@1.2.3

Other: vue-turnstile, ngx-turnstile, svelte-turnstile, @nuxtjs/turnstile

Official Documentation

  • Use mcp__cloudflare-docs__search_cloudflare_documentation tool

Troubleshooting

Problem: Error 110200 - "Unknown domain"

Solution: Add your domain (including localhost for dev) to widget's allowed domains in Cloudflare Dashboard. For local dev, use dummy test sitekey 1x00000000000000000000AA instead.

Problem: Error 300030 - Widget crashes for legitimate users

Solution: Implement error callback with retry logic. This is a known Cloudflare-side issue (2025). Fallback to alternative verification if retries fail.

Problem: Tokens always return success: false

Solution:

  • Check token hasn't expired (5 min TTL)
  • Verify secret key is correct
  • Ensure token hasn't been validated before (single-use)
  • Check hostname matches widget configuration

Problem: CSP blocking iframe (Error 200500)

Solution: Add CSP directives:

<meta http-equiv="Content-Security-Policy" content="

  frame-src https://challenges.cloudflare.com;

  script-src https://challenges.cloudflare.com;

">

Problem: Safari 18 "Hide IP" causing Error 300010

Solution: Document in error message that users should disable Safari's "Hide IP address" setting (Safari → Settings → Privacy → Hide IP address → Off)

Problem: Next.js + Jest tests failing with @marsidev/react-turnstile

Solution: Mock the Turnstile component in Jest setup (or migrate to Vitest, which handles ESM modules correctly):

// Option 1: Jest mocking (jest.setup.ts)

jest.mock('@marsidev/react-turnstile', () => ({

  Turnstile: () => <div data-testid="turnstile-mock" />,

}))

// Option 2: Migrate to Vitest (recommended for new projects)

// Vitest handles ESM modules without mocking required

Note: This issue persists in Jest 30.2.0 (Dec 2025). Maintainer closed as "not planned". See Issue #15 for full details.

Errors Prevented: 15 documented issues (Safari 18 Hide IP, Brave confetti, Next.js Jest, CSP blocking, token reuse, expiration, hostname allowlist, widget crash 300030, config error 600010, missing validation, GET request, secret exposure, Chrome/Edge 106010, multiple widgets rendering, token regeneration pattern)

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card