Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
884e15f
[Turnstile] Set autocomplete hints on client-side rendering examples …
Jun 17, 2026
758ec83
[Turnstile] Spin: document dashboard setup path alongside command-line
Jun 17, 2026
a58cd2c
[Turnstile] Spin docs: address style-bot suggestions
Jun 17, 2026
e19dffb
[Turnstile] Spin V2 docs: point references at prompt.md
Jun 17, 2026
a6edc4f
[Turnstile] Spin V2 docs: dashboard recovery is the primary path, age…
Jun 17, 2026
84fd4a2
[Turnstile] Spin docs: pivot to canonical siteverify
Jun 29, 2026
da161d3
[Turnstile] Spin V2 sync: prompt.md + Wrangler CLI setup path
Jul 9, 2026
e91c08b
[Turnstile] Drop Wrangler version note from Spin CLI section
Jul 9, 2026
2a360a6
[Turnstile] Fix Spin docs accuracy (removed column, local dev domains…
Jul 9, 2026
82cb7a8
Merge remote-tracking branch 'upstream/production' into spin-docs-fol…
Jul 10, 2026
b6cc3b6
[Turnstile] Drop Spin beta tag; bump reviewed date
Jul 10, 2026
2985122
[Turnstile] Redirect old Spin index.md to prompt.md
Jul 10, 2026
14662f3
Revert "[Turnstile] Redirect old Spin index.md to prompt.md"
Jul 10, 2026
05c95a8
[Turnstile] Spin docs: spell out insertion-preference examples per re…
Jul 10, 2026
e0c74a5
[Turnstile] Spin: sync prompt.md to SKILL.md and guide token reset fo…
Jul 17, 2026
7995c2d
[Turnstile] Spin docs: merge upstream/production and address review w…
Jul 21, 2026
f671609
[Turnstile] Spin docs: broaden scope beyond forms per review
Jul 21, 2026
7c87c00
Harden Turnstile Spin helper scripts (docs-bot findings)
Jul 21, 2026
c179505
Harden Turnstile Spin helper scripts (second pass on docs-bot findings)
Jul 21, 2026
fcb4f75
Guard mktemp, malformed errors list, and npx prerequisite
Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 41 additions & 47 deletions public/turnstile/spin/prompt.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: turnstile-spin
description: 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.
description: Set up Cloudflare Turnstile end-to-end in a project. Scan the codebase, create the widget via the Cloudflare API, embed it where user requests need bot verification (form submissions, SPA actions, API endpoints, download links, comment or vote submissions, etc.), 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 or endpoint from bots, or fix a Turnstile integration. Mirrors developers.cloudflare.com/turnstile/spin.
references:
- vanilla-html
- nextjs-app
Expand All @@ -24,16 +24,16 @@ 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"
- "protect this form", "protect this endpoint", "protect this button", "stop bot signups", "spam signups", "block bots on <target>"
- A specific signup, login, contact form, download, comment, API endpoint, or other user-triggered request 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.

1. **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.
1. **Brief acknowledge.** One sentence: "I'll run Turnstile setup end to end. That's: check auth, scan the codebase, create the widget, embed it where visitor requests need verification, wire server-side siteverify, validate. Proceed?" **[wait for user]** Do NOT present a plan yet. Auth + scan come first.

2. **CLI check.** Spin's helper scripts use `curl` against `api.cloudflare.com` and `npx wrangler whoami` for account enumeration. Widget creation in Step 8 prefers `wrangler turnstile widget create` when the subcommand is available (Wrangler 4.109+), falling back to the bundled curl script otherwise. No persistent CLI install is required.

Expand Down Expand Up @@ -67,23 +67,30 @@ The user pasted the prompt. You are in a multi-step dialog. Detect what you can,

Parse `sitekey` and `secret` from stdout JSON. If wrangler is missing, older than the turnstile subcommand (`unknown command`), or otherwise fails, fall back to `scripts/widget-create.sh --account-id <id> --name <name> --domains <list> --mode managed`, which uses `curl` against the Cloudflare API directly. Report the sitekey. Capture the secret into a shell variable `WIDGET_SECRET`; never write it to disk except into the user's own env / secret store in Step 9.

9. **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 as `TURNSTILE_SECRET`." Ask "yes" / "show". **[wait for user]** If "show", print unified diffs and ask again. Do NOT propose alternate behavior (mail delivery, custom backends).
9. **Wire the integration.** State the contract: "I'll embed the widget at each chosen surface (form, SPA action, endpoint) and add a canonical siteverify call inside your existing handler, gated on `success === true`. The handler logic stays the same. The secret lives in your env as `TURNSTILE_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):

```js
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();
let result;
try {
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.
}),
});
if (!r.ok) throw new Error(`siteverify ${r.status}`);
result = await r.json();
} catch (err) {
// Network error, non-2xx, or non-JSON body from siteverify. Fail closed.
return res.status(403).send('forbidden'); // adapt to your framework
}
if (!result.success) {
return reject(403, 'forbidden'); // platform-appropriate equivalent
return res.status(403).send('forbidden');
}
// existing handler logic runs here, unchanged
```
Expand All @@ -104,13 +111,14 @@ The user pasted the prompt. You are in a multi-step dialog. Detect what you can,
- 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 `sudo` or 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:
Spin validates the Turnstile token via canonical siteverify before the user's existing 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.
- **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`.
Expand All @@ -128,46 +136,38 @@ If the user tells you they already have a Turnstile widget set up and want to wi
- `missing_read_scope`: tell the user to add `Account.Turnstile:Read` to the token, or fall back to asking them to paste the secret. In the paste path, you do not have `clearance_level` or `domains`; ask the user to confirm both.
3. Check `clearance_level` from 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.
4. Continue from Step 9 (Wire the integration). Site key does not change. Dashboard's `Deployment` column flips from `Manual` to `Spin` on the first request carrying `data-action="turnstile-spin-v2"`.
- anything else: exit per the scope boundary. Spin does not apply to pre-clearance widgets; siteverify is optional there and the user should be redirected as described above. Do NOT prompt the user for permission to add siteverify on top.
4. Continue from Step 9 (Wire the integration). Site key does not change; the existing widget keeps working throughout.
5. 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.
When wiring an existing form or user-triggered endpoint (Step 9), the contract is: **gate, don't replace.** The user's existing 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):

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

<form action="/signup" method="POST">
<form action="/signup" method="POST" id="cf-form">
<!-- existing inputs unchanged -->
<div class="cf-turnstile" data-sitekey="<SITEKEY>" data-action="turnstile-spin-v2"></div>
<button type="submit">Sign up</button>
</form>
<script>
// Turnstile tokens are single-use. If the page does not navigate after
// submit (server returned an inline error, client-side validation
// caught something), reset the widget so a retry gets a fresh token
// instead of being rejected as timeout-or-duplicate.
document.getElementById('cf-form').addEventListener('submit', () => {
setTimeout(() => window.turnstile?.reset(), 0);
});
</script>
```

Backend (inside the existing handler; reads the token from the request and gates):

```js
// In the existing POST /signup handler
const token = req.body['cf-turnstile-response'];
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,
remoteip: req.ip,
}),
});
const { success } = await r.json();
if (!success) return res.status(403).end();
// existing handler logic runs here, unchanged
```
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.

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.
**Token lifecycle: tokens are single-use.** A `cf-turnstile-response` token is redeemed exactly once at siteverify. If the server rejects (non-2xx or `success: false`), the browser still holds the redeemed token in the DOM; a naive retry submits the same token and Cloudflare's edge rejects the second attempt with `timeout-or-duplicate`. Always call `window.turnstile.reset()` before the user is allowed to retry. The framework references show the per-framework hook (submit listener for native forms, response handler for AJAX/SPA submits, `onError` for React components).

## Migrating from another CAPTCHA

Expand Down Expand Up @@ -207,10 +207,4 @@ Every `cf-turnstile` div this skill writes must include `data-action="turnstile-

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.

## Do not

- Do not write the secret to disk (other than the user's own env store).
- Do not skip validation (Step 10).
- Do not propose features outside the wizard (custom Workers, custom domains, advanced WAF rules) unless asked.
- Do not call siteverify from the browser.
- Do not deploy any extra infrastructure on the user's behalf.
Loading