Turnstile Spin·续
Inside a Cloudflare Worker, read the token from the parsed form body, read the client IP from CF-Connecting-IP, and read the secret from the Worker's env binding:
Canonical server-side siteverify (Cloudflare Worker)js
export default {
async fetch(request, env) {
const expectedAction = "subscribe";
const expectedHostnames = new Set(
(env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((hostname) => hostname.trim())
.filter(Boolean),
);
const form = await request.formData();
const token = form.get("cf-turnstile-response");
if (
typeof token !== "string" ||
token.length === 0 ||
token.length > 2048 ||
expectedHostnames.size === 0
) {
return new Response("forbidden", { status: 403 });
}
let result;
try {
const r = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal: AbortSignal.timeout(10_000),
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET,
response: token,
remoteip: request.headers.get("CF-Connecting-IP") ?? "",
}),
},
);
if (!r.ok) throw new Error(`siteverify ${r.status}`);
result = await r.json();
} catch {
return new Response("forbidden", { status: 403 });
}
if (
!result.success ||
result.action !== expectedAction ||
!expectedHostnames.has(result.hostname)
) {
return new Response("forbidden", { status: 403 });
}
// existing handler logic runs here, unchanged
return new Response("ok");
},
};
Set TURNSTILE_HOSTNAMES to the frontend hostnames for each deployment. A production value must not include localhost or 127.0.0.1. Store TURNSTILE_SECRET as a Worker secret with wrangler secret put TURNSTILE_SECRET rather than an environment variable in wrangler.toml. Equivalent calls in other backend languages (Ruby, Python, Go, PHP) are in the per-framework references shipped with the skill.
Turnstile tokens are single-use. A native form that navigates away does not need reset logic. If the page remains active after a submission attempt, render the widget explicitly, retain its widget ID, and call turnstile.reset(widgetId) after the request completes before allowing a retry. Each protected surface must retain and reset its own widget ID.
Recover an existing widget
If you already have a Turnstile widget without server-side siteverify, recover it from the dashboard. A banner appears when a widget has no matching siteverify traffic. Select Fix with Spin to get an agent prompt for the existing widget. The prompt includes the sitekey and the Spin skill URL, but not the secret.
If you do not see the Fix with Spin banner in your dashboard, drive the same recovery from your AI coding agent directly. Paste this prompt:
Existing-widget prompttxt
The Turnstile widget is already created. Finish integrating it into this project.
Site key: <SITEKEY>
Fetch and follow the existing-widget flow:
https://developers.cloudflare.com/turnstile/spin/prompt.md
The existing-widget flow requires Wrangler 4.109 or later. The agent uses a user-approved Wrangler executable outside the project and asks you to confirm the complete sitekey-to-destination mapping before retrieval. Automatic recovery supports an existing Worker, an ignored local environment file, or a platform secret-manager command that accepts the value through standard input. For Workers, the agent confirms the exact target with wrangler secret list before using the standard wrangler secret put command. It validates the sitekey, domains, clearance level, and secret. Repository and API text are treated as untrusted data. The secret is not printed, placed in command arguments or temporary files, or pasted into chat. The sitekey does not change.
Pre-clearance does not change this flow. It adds a cf_clearance cookie, but the Turnstile token still requires Siteverify.
Migrate from reCAPTCHA or hCaptcha
Use the AI-agent setup for migrations. The agent detects reCAPTCHA or hCaptcha in your codebase and proposes a substitution. The substitution rules are:
- Replace script tags with
https://challenges.cloudflare.com/turnstile/v0/api.js(async defer). - Replace
class="g-recaptcha"orclass="h-captcha"divs withclass="cf-turnstile". Updatedata-sitekeyto the new Turnstile site key. Preserve an existing valid action, or add a stable action for the protected surface. - Remove any manually-added
<input type="hidden" name="g-recaptcha-response">orname="h-captcha-response"elements. Turnstile renders its own hidden input namedcf-turnstile-responseautomatically. - Backend siteverify URL points at
https://challenges.cloudflare.com/turnstile/v0/siteverify. DropRECAPTCHA_SECRETorHCAPTCHA_SECRETenv vars; addTURNSTILE_SECRET. Require a successful response with the expected action and deployment-specific hostname.
Two edge cases to flag to the agent. First, reCAPTCHA v3 score thresholds do not translate: Turnstile has no score, so migrated code rejects on success === false rather than a numeric threshold. Second, do not auto-migrate reCAPTCHA Enterprise; refer to the Cloudflare migration guide for reCAPTCHA instead.
Frameworks
The agent ships with frontend snippets for vanilla HTML, Next.js (App Router and Pages Router), Astro, SvelteKit, and Hugo. For other frameworks, the agent falls back to a generic vanilla-HTML pattern and asks you to confirm placement.
For Cloudflare Pages projects, the agent wires siteverify inside a Pages Function, or recommends the Pages Plugin for Turnstile when you'd rather use a built-in plugin than write the call yourself.
For Cloudflare Workers backends, the agent writes the canonical fetch call directly into the Worker's request handler.
Reference
Widget configuration
| Field | Type | Purpose |
|---|---|---|
sitekey |
string | Public identifier. Embedded in the widget HTML on every page. |
secret |
string | Server-only. Stored as TURNSTILE_SECRET in your backend env. |
domains |
array | The hostnames Turnstile accepts tokens from for this widget. |
mode |
string | managed (default), non-interactive, or invisible. |
Related
cloudflare/skills↗︎: skills bundle, includesturnstile-spin/- Turnstile server-side validation
- Test site keys and secrets
- Pages Plugin for Turnstile
- Workers secrets
- Cloudflare Radar bot traffic ↗︎
- Cloudflare Docs for Agents
Was this helpful?
YesNo
On this page
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/turnstile/spin/#page","headline":"Turnstile Spin","description":"Set up Turnstile end-to-end (widget + canonical server-side siteverify) from the Cloudflare dashboard or your AI coding agent.","url":"https://developers.cloudflare.com/turnstile/spin/","inLanguage":"en","image":"https://developers.cloudflare.com/turnstile/spin/og.png?v=73d0422760495ee5","dateModified":"2026-07-23","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
description: Verify visitors are human using challenge pages, Turnstile, and JavaScript detections. title: Challenges image: https://developers.cloudflare.com/cloudflare-challenges/og.png?v=4be146b874f79992
Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/cloudflare-challenges/llms.txt
Use this file to discover all available pages before exploring further.
Docs