All skills
cloudflare avatar

/turnstile-spin

@41e0d19 official
by cloudflarecloudflare/skills3k stars
298

Set up, repair, or migrate to Cloudflare Turnstile bot verification in an existing frontend and backend, including server-side Siteverify.

Use this Skill: https://skilld.dev/gh/cloudflare/skills/turnstile-spin

This session only. Nothing lands on disk.

referencesastro.md

≈1.5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Astro

For Astro projects. The widget renders in a page; siteverify lives in an Astro Action, an API route, or a Pages Function. Astro frontmatter reads the sitekey from env at build time; the secret stays server-only.

---
const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
---

<html>
	<head>
		<script
			src="https://challenges.cloudflare.com/turnstile/v0/api.js"
			async
			defer
		></script>
	</head>
	<body>
		<form action="/api/signup" method="POST">
			<input name="email" type="email" required />
			<div
				class="cf-turnstile"
				data-sitekey={SITEKEY}
				data-action="signup"
			/>
			<button type="submit">Sign up</button>
		</form>
	</body>
</html>

In your .env:

PUBLIC_TURNSTILE_SITEKEY=YOUR_SITEKEY
TURNSTILE_SECRET=YOUR_SECRET

The PUBLIC_ prefix is mandatory for client-exposed variables in Astro. The secret has no prefix; it stays server-only.

API route (canonical siteverify)

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");
	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",
		headers: { "Content-Type": "application/x-www-form-urlencoded" },
		body: new URLSearchParams({
			secret: import.meta.env.TURNSTILE_SECRET,
			response: token,
			remoteip: clientAddress,
		}),
	});
	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 });
};

Variant: Astro Actions

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",
		input: z.object({
			email: z.string().email(),
			"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" },
				body: new URLSearchParams({
					secret: import.meta.env.TURNSTILE_SECRET,
					response: input["cf-turnstile-response"],
					remoteip: ctx.clientAddress,
				}),
			});
			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:

<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
YOUR_SITEKEY The widget site key from Step 8
YOUR_SECRET The secret captured in Step 8. Stays in env, never inlined.

Source: SKILL.md on GitHub

No alertstoday3 checks · Risk SAFE
  • Gen Agent Trust Hubtoday

    This skill provides a structured workflow for integrating Cloudflare Turnstile bot protection into web applications. It implements robust security practices, particularly regarding the handling of sensitive API tokens and secrets. While the skill interacts with project files and external Cloudflare APIs, it includes specific guardrails to prevent data exfiltration and unauthorized command execution. See the detailed analysis for a breakdown of the security controls in place.

  • Sockettoday

    No alerts

  • Snyktoday

    Risk: LOW · No issues

Signed by skilld at 41e0d19. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 10 hours ago.

Activeupdated 11 hours ago
  • turnstile
  • cloudflare
  • captcha
  • bot-protection
  • workers
  • form-validation
  • siteverify
  • javascript

README badge

README badge for cloudflare/skills/turnstile-spin

Sets up Cloudflare Turnstile end-to-end: creates the widget via API, deploys a siteverify Worker, wires frontend snippets, and validates the integration. Targets projects using vanilla HTML, Next.js, Astro, SvelteKit, or Hugo and includes migration paths from reCAPTCHA or hCaptcha.

Generated from the current SKILL.md.

Does this skill work with my framework?
Yes. The skill has templates for vanilla HTML, Next.js (app and pages), Astro, SvelteKit, and Hugo. It detects your framework during the codebase scan and adapts the frontend edits accordingly.
Will this overwrite my existing form code?
No. The skill gates your existing submit handler on Turnstile validation success but leaves the handler logic unchanged. It only adds the token fetch and Worker call before your code runs.
What if I already have a Turnstile widget set up?
The skill can wire siteverify to your existing sitekey without recreating the widget. Provide your sitekey and the skill will fetch its secret, deploy the Worker, and integrate validation into your form.
Can this skill migrate from reCAPTCHA or hCaptcha?
Yes. If the skill detects reCAPTCHA or hCaptcha during codebase scan, it will present a migration plan that swaps the script tag, updates the widget class and data attributes, and routes validation through the deployed Worker.
Does the Turnstile secret stay secure?
Yes. The secret is never written to disk. It's passed via stdin directly to `wrangler secret put` and stored as a Worker environment variable.

Generated from the current SKILL.md. These answers refresh after source changes.