Turnstile spin
Set up Cloudflare Turnstile end-to-end in a project. Scan the codebase, create the widget via the Cloudflare API, embed it on the right forms, wire canonical server-side siteverify in the customer's existing backend, validate, and persist the skill. Load this when a user asks to add Turnstile, set up CAPTCHA, protect a form from bots, or fix a Turnstile integration. Mirrors developers.cloudflare.com/turnstile/spin.From its SKILL.md
npx -y skills add K95M65/AI_ONBOARD --skill turnstile-spinAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 25 days oldThe repository was created 25 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
16.7 KB, ~3.6k tokens by cl100k_base, as published. Nobody here has run it
Turnstile Spin skill
Turns the prompt "set up Turnstile" into a working end-to-end integration: a widget, frontend snippets at every chosen insertion point, canonical server-side siteverify in the customer's existing backend, and a real validation pass before reporting success.
You are the agent. Run the wizard below by invoking the scripts under scripts/ and branching on their JSON output. The scripts hold the deterministic logic (API calls, retry/error handling); your job is orchestration, codebase reading, confirmation, and the frontend + backend edits.
Canonical instructions live at developers.cloudflare.com/turnstile/spin. If the docs page and this file disagree, trust the docs page.
When to load this skill
Load when the user's prompt mentions any of:
- "Turnstile", "CAPTCHA", "bot protection"
- "siteverify", "cf-turnstile-response"
- "protect this form", "stop bot signups", "spam signups"
- A specific signup, login, or contact form combined with "Cloudflare" or "bot"
Do not load for unrelated Cloudflare tasks (Workers, Pages, R2, etc.) unless Turnstile is also mentioned.
Conversation flow
The user pasted the prompt. You are in a multi-step dialog. Detect what you can, ask only when you have to, confirm before every irreversible step. Each numbered moment is one agent message. Items marked [wait for user] require a user response.
-
Brief acknowledge. One sentence: "I'll run Turnstile setup end to end. That's: check auth, scan the codebase, create the widget, embed it on the right forms, wire server-side siteverify, validate. Proceed?" [wait for user] Do NOT present a plan yet. Auth + scan come first.
-
CLI check. Spin's helper scripts use
curlagainstapi.cloudflare.com. No persistent CLI install is required. -
Auth + scope probe (FIRST irreversible action). Run
scripts/auth-probe.sh. Branch onstatus:ok: continue to Step 4. The script already picked the account (single-account token, or one matching$CLOUDFLARE_ACCOUNT_ID).missing_tokenormissing_scope: ask the user to create a token at https://dash.cloudflare.com/profile/api-tokens → Custom token → permissionAccount.Turnstile:Edit→ include the target account in Account Resources. Never ask the user to paste the token in chat. Have them exportCLOUDFLARE_API_TOKENin the launching shell or load it from an approved user-level secret store, then restart the agent from that shell. When auth is established, re-runauth-probe.sh, then continue to Step 8.multiple_accounts: the token covers more than one account and$CLOUDFLARE_ACCOUNT_IDis unset. Present the numberedaccountslist. [wait for user] Then exportCLOUDFLARE_ACCOUNT_ID=<chosen>and re-runauth-probe.sh.account_mismatch:$CLOUDFLARE_ACCOUNT_IDis set but isn't one of the token's accounts. Show theaccountslist and ask the user to eitherunset CLOUDFLARE_ACCOUNT_IDor set it to one of those IDs.
-
Account selection. If
auth-probe.shreturnedokafter amultiple_accountsround-trip, this is already done. Otherwise the script picked the single account silently and you continue to Step 5. -
Domain. Always include
localhostand127.0.0.1. For production, scanpackage.jsonhomepage,wrangler.toml,README.md,AGENTS.md, git remote. Confirm: "I'll register forlocalhost,127.0.0.1, and<domain>. OK?" [wait for user] If no production domain is found, ask. -
Codebase scan. Detect three things silently:
- Frontend framework (Next.js, Astro, SvelteKit, Hugo, vanilla, etc.) → drives the widget embed snippet.
- Backend handler location (Express route, Next.js API route, Rails controller, Workers fetch handler, Pages Function, etc.) → drives the siteverify snippet.
- Existing CAPTCHA (reCAPTCHA / hCaptcha) → switches Step 7 to migration mode.
-
Insertion plan. Show the candidate list with
[recommended]/[skip by default]markers; ask the user to confirm (numbers, "all", "recommended", or a list). [wait for user] If an existing CAPTCHA was detected, present a migration plan instead (see "Migrating from another CAPTCHA"). -
Widget creation. Create a private temporary directory and have the bundled API helper write the secret to a new mode-
0600transfer file:secret_dir="$(mktemp -d)" chmod 700 "$secret_dir" scripts/widget-create.sh \ --account-id "<id>" \ --name "<name>" \ --domains "<d1>,<d2>" \ --mode managed \ --secret-file "$secret_dir/turnstile-secret"Parse the sitekey from stdout JSON. The secret must never appear in stdout, stderr, chat, a command argument, or a shell variable. The helper refuses to overwrite an existing destination.
-
Wire the integration. State the contract: "I'll embed the widget on each chosen form and add a canonical siteverify call inside your existing submit handler, gated on
success === true. The handler logic stays the same. The secret lives in your env asTURNSTILE_SECRET." Ask "yes" / "show". [wait for user] If "show", print unified diffs and ask again. Do NOT propose alternate behavior (mail delivery, custom backends).Canonical server-side siteverify (Node / fetch idiom; adapt to the detected backend):
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: process.env.TURNSTILE_SECRET, response: token, // cf-turnstile-response from the request remoteip: clientIp, // X-Forwarded-For / req.ip / etc. }), }); const result = await r.json(); if (!result.success) { return reject(403, 'forbidden'); // platform-appropriate equivalent } // existing handler logic runs here, unchangedTransfer the secret from the restricted file into the user's approved platform secret manager (
wrangler secret put TURNSTILE_SECRETfor Workers, or the secret manager for Vercel / Fly / Render / etc.) without printing it.Use a local
.env-style file only after all of these checks pass for the exact destination:git check-ignore -q -- .env && ! git ls-files --error-unmatch -- .env >/dev/null 2>&1If the file is tracked, not ignored, or the project is not a Git repository, stop and use a platform or user-level secret store. Before writing, apply
umask 077and mode0600. Delete the temporary transfer file and directory immediately after a successful transfer. Never inline the value. -
Validation. Run
scripts/validate.sh. Report each check as it passes. If any fails, surface the error and stop. [wait for user if anything fails] -
Persist skill. Ask: "Save the Spin skill to
.claude/skills/turnstile-spin/SKILL.mdso I can reuse it on follow-up tasks?" Default yes. [wait for user] Then runscripts/persist-skill.sh --path <agent-specific-path>. -
Final report. Print the structured summary: what was created, what was validated, what to do next.
Things you must NOT do
- Do not expose the Turnstile secret in stdout, stderr, chat, command arguments, or shell variables.
- A restricted temporary transfer file is allowed only for the bundled helper flow; remove it immediately after storing the secret in the user's approved secret store.
- Do not skip validation.
- Do not overwrite files without showing a diff.
- Do not call siteverify from the browser. Always: browser → user's backend → siteverify.
- Do not deploy any extra infrastructure (Workers, proxies, sidecars). The customer's existing backend calls siteverify directly.
- Do not use
sudoor install global packages without asking. - Do not propose features outside the wizard (custom Workers, custom domains, advanced WAF rules) unless asked.
Hard scope boundary: DO NOT ask the user about
Spin validates the Turnstile token via canonical siteverify before the user's existing form handler runs. Everything else is out of scope:
- Email / SMS / notification delivery. Leave the existing submit handler alone (just gate it on
success === true). Don't propose Resend, Mailchannels, SMTP, mailto. - Adding a new backend. If the form has no backend handler today (pure-static site, mailto-only contact form), say so and exit. Spin requires a server-side place to put siteverify.
- Database / payment / OAuth / form persistence. Out of scope.
- Frontend framework migration, refactoring, or styling. Edit only what's needed.
- reCAPTCHA v3 score thresholds. Turnstile returns
success: true/false. - Pre-clearance-only setups. If
clearance_level !== no_clearance, siteverify is optional and Spin doesn't apply. Redirect the user and exit.
Recovery flow: respect existing widget configuration
When the user has Cloudflare dashboard access, the in-dashboard Fix with Spin banner is a one-click recovery path: it shows a curated agent prompt for the existing widget. This skill's recovery flow below is the equivalent when the user is driving from their editor.
If the user tells you they already have a Turnstile widget set up and want to wire siteverify to it without rotating the sitekey (e.g. "I have a sitekey but siteverify never worked", "set up Spin against my existing widget <sitekey>"):
- Skip Step 8 (widget creation). The sitekey already exists; get it from the user.
- Create a private temporary directory, then fetch the widget metadata with
scripts/fetch-secret.sh --account-id <id> --sitekey <key> --secret-file <restricted-new-path>. Branch onstatus:ok: readclearance_levelanddomainsfrom stdout. The secret is available only in the restricted transfer file. Confirmdomainsincludes the user's production hostname; if not, surface the gap before proceeding.missing_read_scope: tell the user to addAccount.Turnstile:Reador retrieve the secret through the Cloudflare dashboard into their approved secret store. Never ask them to paste it in chat.
- Check
clearance_levelfrom the response (or the user's answer):no_clearance: standard wire-up (Step 9).- anything else: ask whether they want siteverify on top of pre-clearance, or exit per the scope boundary.
- Continue from Step 9 (Wire the integration). Site key does not change; the existing widget keeps working throughout.
- Never recreate the widget to get a fresh secret. That breaks the existing sitekey everywhere it's deployed.
The frontend-edit contract
When wiring an existing form (Step 9), the contract is: gate, don't replace. The user's existing submit handler keeps doing what it did. Spin only adds a validation step before it.
Frontend (embeds the widget; submits to the user's existing endpoint):
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form action="/signup" method="POST">
<!-- existing inputs unchanged -->
<div class="cf-turnstile" data-sitekey="<SITEKEY>" data-action="turnstile-spin-v2"></div>
<button type="submit">Sign up</button>
</form>
Backend: use the canonical siteverify fetch from Step 9 inside the existing handler. Read the token from req.body['cf-turnstile-response'], gate on success === true, and leave the rest of the handler alone. If the existing handler was a stub, Spin leaves it a stub gated on success. The user can replace the stub later; that's not Spin's job.
Migrating from another CAPTCHA
During the Step 6 codebase scan, also look for existing reCAPTCHA or hCaptcha. If found, switch Step 7 to a migration plan.
Detection signals:
- reCAPTCHA:
https://www.google.com/recaptcha/api.js,class="g-recaptcha",data-sitekey="6L...", backend POST to/recaptcha/api/siteverify - hCaptcha:
https://js.hcaptcha.com/1/api.js,class="h-captcha", backend POST tohttps://hcaptcha.com/siteverify
Substitution:
- Replace script tags with
https://challenges.cloudflare.com/turnstile/v0/api.js(async defer). - Replace
class="g-recaptcha"/class="h-captcha"divs withclass="cf-turnstile", updatedata-sitekeyto the new Turnstile sitekey, adddata-action="turnstile-spin-v2". - Token field changes from
g-recaptcha-responsetocf-turnstile-response. - Backend siteverify URL points at
https://challenges.cloudflare.com/turnstile/v0/siteverify. DropRECAPTCHA_SECRET/HCAPTCHA_SECRETenv vars; addTURNSTILE_SECRET.
Edge cases to surface to the user:
- reCAPTCHA v3 score thresholds. Turnstile has no score. Tell the user explicitly that migrated code will reject on
success === false. - reCAPTCHA Enterprise. Don't auto-migrate. Point at developers.cloudflare.com/turnstile/migration/recaptcha/.
- Custom
action=values. Preserve any custom action the user passed togrecaptcha.executeasdata-actionon the widget. Useturnstile-spin-v2only when no custom action exists.
Edge cases
| Situation | Action |
|---|---|
| Account enumeration fails | Confirm the token is active and can call GET https://api.cloudflare.com/client/v4/accounts; set $CLOUDFLARE_ACCOUNT_ID to an account covered by the token and re-run the probe. |
| Multiple Cloudflare accounts | scripts/auth-probe.sh returns all accounts; ask the user to choose, export CLOUDFLARE_ACCOUNT_ID |
| Cloudflare Pages project | Wire siteverify inside a Pages Function (or the equivalent for your framework). The Pages Plugin at developers.cloudflare.com/pages/functions/plugins/turnstile is a shortcut. |
| Cloudflare Workers backend | Use the canonical fetch idiom from Step 9 inside the Worker's request handler. fetch to challenges.cloudflare.com works the same way it does in Node. |
EXPECTED_HOSTNAME mismatch | Update widget domains via PUT, not PATCH (PATCH returns 10405 Method not allowed): curl -X PUT .../widgets/$SITEKEY -d '{"name":"...","mode":"managed","domains":[...]}' |
| Token expired mid-flow | Stop, re-run scripts/auth-probe.sh, prompt for fresh credentials |
Validation returns invalid-input-secret | The secret didn't reach the backend. Re-check TURNSTILE_SECRET in the customer's env / secret manager. If it's a Workers backend, run wrangler secret list to confirm the secret is bound to the right script. |
Validation returns invalid-input-response | Expected for a dummy probe token; that means the secret IS valid. validate.sh treats this as success. |
Telemetry marker
Every cf-turnstile div this skill writes must include data-action="turnstile-spin-v2". Account-level aggregate telemetry, never per-user. Cloudflare uses it to measure activation. If the user removes the attribute, the integration still works; only the analytics segmentation is lost.
Older widgets stamped turnstile-spin-v1 (from the V1 agent flow that deployed a managed Worker) still exist in production accounts; preserve that marker if you encounter it on an existing widget you are modifying. Do not retag.
What ships with it: 13 files
41.2 KB alongside SKILL.md, 5 of them executable
references/
- astro.md3.0 KB
- hugo.md3.0 KB
- nextjs-app.md3.9 KB
- nextjs-pages.md1.9 KB
- sveltekit.md2.8 KB
- vanilla-html.md3.6 KB
scripts/
- auth-probe.shruns3.9 KB
- fetch-secret.shruns4.1 KB
- persist-skill.shruns2.1 KB
- validate.shruns4.1 KB
- widget-create.shruns3.4 KB
tests/
- validation.md2.2 KB
- README.md3.3 KB
Gives 0 of the 12 instructions most data backend skills give in ~3.6k tokens
Counted across 229 of the 229 authors here whose files we hold, read 2026-08-07
- Separate business logic into service layersin 22 of 229, across 15 files
- Retry failures with exponential backoffin 21 of 229, across 14 files
- Select only needed database columnsin 20 of 229, across 13 files
- Abstract data access into repository classesin 19 of 229, across 12 files
- Use centralized error handlersin 17 of 229, across 10 files
- Use AsNoTracking for read-only queriesin 16 of 229, across 4 files
- Use async/await for all I/O operationsin 16 of 229, across 5 files
- Implement structured loggingin 15 of 229, across 4 files
- Use dependency injection for all servicesin 14 of 229, across 2 files
- Use resource-based URLs for REST APIsin 13 of 229, across 7 files
- Invalidate cache after data changesin 13 of 229, across 9 files
- Use a dependency injection containerin 12 of 229, across 4 files
Said here and by no other author read
- create a Turnstile widget via API
- embed the widget on chosen forms
- add server-side siteverify to existing handler
- gate handler on success being true
- store the secret in approved platform manager
- run the validation script
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.