Deft
Ask AI

Search

Search skills, authors and topics

cloudflare / skills

turnstile-spin

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

Install

npx skills add https://github.com/cloudflare/skills --skill turnstile-spin

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.

astro
---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:

text
PUBLIC_TURNSTILE_SITEKEY=YOUR_SITEKEYTURNSTILE_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)

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");	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:

ts
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:

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

PlaceholderReplace with
YOUR_SITEKEYThe widget site key from Step 8
YOUR_SECRETThe secret captured in Step 8. Stays in env, never inlined.