Skip to content
Merged
25 changes: 13 additions & 12 deletions skills/turnstile-spin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

End-to-end setup skill for Cloudflare Turnstile. Loads when an agent is asked to add Turnstile, set up CAPTCHA, or protect a form from bots.

This is a mirror of the canonical docs page at [`developers.cloudflare.com/turnstile/spin`](https://developers.cloudflare.com/turnstile/spin/). If the two disagree, the docs page wins.
`SKILL.md` is the canonical machine-readable behavior. The hosted prompt at [`developers.cloudflare.com/turnstile/spin/prompt.md`](https://developers.cloudflare.com/turnstile/spin/prompt.md) packages the same behavior for agents that do not have this bundle installed. Product requirements come from the [Turnstile documentation](https://developers.cloudflare.com/turnstile/).

## Layout

Expand All @@ -11,7 +11,6 @@ This is a mirror of the canonical docs page at [`developers.cloudflare.com/turns
| `SKILL.md` | Main wizard instructions for the agent |
| `scripts/auth-probe.sh` | Probes the customer's Cloudflare API token for Turnstile scope |
| `scripts/widget-create.sh` | Creates the Turnstile widget via the Cloudflare API |
| `scripts/fetch-secret.sh` | Retrieves the secret for an existing widget (recovery flow) |
| `scripts/validate.sh` | Dummy-siteverify + hostname check at the end of the wizard |
| `scripts/persist-skill.sh` | Installs the canonical skill bundle into the user's repo |
| `references/vanilla-html.md` | Code snippet for static / vanilla HTML projects |
Expand All @@ -24,24 +23,26 @@ This is a mirror of the canonical docs page at [`developers.cloudflare.com/turns

## How agents load it

Agents that load skill bundles from `github.com/cloudflare/skills` will pick this up automatically. For agents that load skills out of a local directory:
Agents that load skill bundles from `github.com/cloudflare/skills` will pick this up automatically. For agents that load skills out of a local directory, clone the bundle once and symlink it:

```sh
git clone https://github.com/cloudflare/skills ~/.config/cloudflare-skills
ln -s ~/.config/cloudflare-skills/skills/turnstile-spin ~/.claude/skills/turnstile-spin
```

If cloning is not an option, the hosted single-file prompt is a read-only fallback:

```sh
# Claude Code
mkdir -p .claude/skills/turnstile-spin && \
curl -sSL https://developers.cloudflare.com/turnstile/spin.md \
curl -sSL https://developers.cloudflare.com/turnstile/spin/prompt.md \
-o .claude/skills/turnstile-spin/SKILL.md

# Or, install the whole skills bundle into a global location
git clone https://github.com/cloudflare/skills ~/.config/cloudflare-skills
ln -s ~/.config/cloudflare-skills/turnstile-spin ~/.claude/skills/turnstile-spin
```

For other agents, see the table in [`SKILL.md`](./SKILL.md#step-11--persist-the-skill).
The single-file install does not include `scripts/` or `references/`; the hosted prompt fetches those on demand with `fetch_spin_script`. `scripts/persist-skill.sh` requires the cloned bundle above and cannot be used from a single-file install. For other agents, see the table in [`SKILL.md`](./SKILL.md#step-11--persist-the-skill).

## Sync with the docs page
## Keep the hosted prompt in sync

The canonical source of truth is `src/content/docs/turnstile/spin.mdx` in the `cloudflare-docs` repo. This skill mirrors that content with the JSX stripped out. CI keeps them in sync on each docs release; if you are hand-editing, mirror your change to both places.
Any behavioral change to `SKILL.md` must also be applied to `public/turnstile/spin/prompt.md` in the `cloudflare-docs` repository. The hosted file adds bootstrap instructions, but its wizard, security boundaries, recovery flow, and validation requirements must match this skill.

## Related

Expand Down
264 changes: 201 additions & 63 deletions skills/turnstile-spin/SKILL.md

Large diffs are not rendered by default.

106 changes: 100 additions & 6 deletions skills/turnstile-spin/references/astro.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
<div
class="cf-turnstile"
data-sitekey={SITEKEY}
data-action="turnstile-spin-v2"
data-action="signup"
/>
<button type="submit">Sign up</button>
</form>
Expand All @@ -43,9 +43,19 @@ The `PUBLIC_` prefix is mandatory for client-exposed variables in Astro. The sec
```ts title="src/pages/api/signup.ts"
import type { APIRoute } from "astro";

const expectedHostnames = new Set(
(import.meta.env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean),
);

export const POST: APIRoute = async ({ request, clientAddress }) => {
const form = await request.formData();
const token = form.get("cf-turnstile-response") as string;
const token = form.get("cf-turnstile-response");
if (typeof token !== "string" || expectedHostnames.size === 0) {
return new Response("forbidden", { status: 403 });
}

const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
Expand All @@ -56,8 +66,15 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
remoteip: clientAddress,
}),
});
const { success } = await verify.json();
if (!success) return new Response("forbidden", { status: 403 });
const result = await verify.json();
if (
verify.ok !== true ||
result.success !== true ||
result.action !== "signup" ||
!expectedHostnames.has(result.hostname)
) {
return new Response("forbidden", { status: 403 });
}

// process signup
return Response.json({ ok: true });
Expand All @@ -72,6 +89,13 @@ If the project uses Astro Actions, call siteverify from the action:
import { defineAction } from "astro:actions";
import { z } from "astro:schema";

const expectedHostnames = new Set(
(import.meta.env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean),
);

export const server = {
signup: defineAction({
accept: "form",
Expand All @@ -80,6 +104,7 @@ export const server = {
"cf-turnstile-response": z.string(),
}),
handler: async (input, ctx) => {
if (expectedHostnames.size === 0) throw new Error("Verification failed");
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
Expand All @@ -89,14 +114,83 @@ export const server = {
remoteip: ctx.clientAddress,
}),
});
const data = await verify.json();
if (!data.success) throw new Error("Verification failed");
const result = await verify.json();
if (
verify.ok !== true ||
result.success !== true ||
result.action !== "signup" ||
!expectedHostnames.has(result.hostname)
) {
throw new Error("Verification failed");
}
// process signup
},
}),
};
```

`signup` is the stable action for this surface. Preserve an existing custom migration action and compare the returned action to the same value. Siteverify is mandatory for every widget mode, including pre-clearance. Set `TURNSTILE_HOSTNAMES` to the deployment-specific frontend hostnames; a production value must not include `localhost` or `127.0.0.1`.

For a client-side Astro Action, replace the native form and script with an explicit widget. Retain this surface's widget ID and reset it in `finally` after every same-page request completion:

```astro
<form id="signup-action-form">
<input name="email" type="email" required />
<div id="signup-action-turnstile" data-sitekey={SITEKEY}></div>
<button type="submit">Sign up</button>
</form>
<script>
import { actions } from "astro:actions";

type TurnstileApi = {
render: (
container: HTMLElement,
options: { sitekey: string; action: string },
) => string;
reset: (widgetId: string) => void;
};

const turnstileWindow = window as Window & { turnstile?: TurnstileApi };
const form = document.getElementById("signup-action-form") as HTMLFormElement;
const container = document.getElementById("signup-action-turnstile") as HTMLElement;
let signupActionWidgetId: string | undefined;

const renderWidget = () => {
if (!turnstileWindow.turnstile) return;
signupActionWidgetId = turnstileWindow.turnstile.render(container, {
sitekey: container.dataset.sitekey!,
action: "signup",
});
};

if (turnstileWindow.turnstile) {
renderWidget();
} else {
const script = document.createElement("script");
script.src =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.addEventListener("load", renderWidget, { once: true });
document.head.appendChild(script);
}

form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const { error } = await actions.signup(new FormData(form));
if (error) throw error;
// proceed
} catch {
// surface the error
} finally {
if (signupActionWidgetId !== undefined) {
turnstileWindow.turnstile?.reset(signupActionWidgetId);
}
}
});
</script>
```

## Substitutions

| Placeholder | Replace with |
Expand Down
27 changes: 23 additions & 4 deletions skills/turnstile-spin/references/hugo.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ For Hugo static sites. The widget renders on any page that includes the partial;
<div
class="cf-turnstile"
data-sitekey="{{ .Site.Params.turnstileSitekey }}"
data-action="turnstile-spin-v2"
data-action="subscribe"
></div>
<button type="submit">Subscribe</button>
</form>
Expand Down Expand Up @@ -45,6 +45,16 @@ export async function onRequestPost({ request, env }) {
const form = await request.formData();
const token = form.get("cf-turnstile-response");

const expectedHostnames = new Set(
(env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean),
);
if (expectedHostnames.size === 0) {
return new Response("forbidden", { status: 403 });
}

const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
Expand All @@ -54,15 +64,24 @@ export async function onRequestPost({ request, env }) {
remoteip: request.headers.get("CF-Connecting-IP"),
}),
});
const { success } = await r.json();
if (!success) return new Response("forbidden", { status: 403 });
const result = await r.json();
if (
r.ok !== true ||
result.success !== true ||
result.action !== "subscribe" ||
!expectedHostnames.has(result.hostname)
) {
return new Response("forbidden", { status: 403 });
}

// process subscribe
return new Response("ok");
}
```

Set the secret with `npx wrangler pages secret put TURNSTILE_SECRET` (or via the dashboard's Pages → your project → Settings → Environment variables → Add secret).
`subscribe` is the stable action for this surface. Preserve an existing custom migration action and compare the returned action to the same value. Siteverify is mandatory for every widget mode, including pre-clearance. Set `TURNSTILE_HOSTNAMES` to the deployment-specific frontend hostnames; a production value must not include `localhost` or `127.0.0.1`.

After the user approves a canonical absolute `WRANGLER_BIN` outside the project, set the secret with `(set +x; printf '%s' "$WIDGET_SECRET" | "$WRANGLER_BIN" pages secret put TURNSTILE_SECRET)` (or use the dashboard's Pages → your project → Settings → Environment variables → Add secret).

**External backend**: any Node/Ruby/Python/Go handler can do the same call. See the [vanilla-html reference](./vanilla-html.md) for non-Cloudflare-specific snippets.

Expand Down
Loading