← Files CloudflareARCHIVED FILE

skills/turnstile-spin/references/vanilla-html.md

5.62 KB · Oct 2, 2026 · 00:28 UTC

↓ Download file

# Vanilla HTML

For static sites or any project without a JS framework. The widget renders client-side; the form submits to whatever backend handles your form (a Node/PHP/Ruby/Go server, a Cloudflare Worker, a Pages Function, a third-party form host that supports server-side hooks, etc.).

```html
<!doctype html>
<html>
	<head>
		<script
			src="https://challenges.cloudflare.com/turnstile/v0/api.js"
			async
			defer
		></script>
	</head>
	<body>
		<form action="/api/subscribe" method="POST">
			<input name="email" type="email" required />
			<div
				class="cf-turnstile"
				data-sitekey="YOUR_SITEKEY"
				data-action="subscribe"
			></div>
			<button type="submit">Subscribe</button>
		</form>
	</body>
</html>
```

When the form submits, the browser includes `cf-turnstile-response` automatically. Your backend reads it and calls canonical siteverify.

## Backend (any language)

Add this to your existing `/api/subscribe` handler before the rest of its logic:

```js
// Node / fetch idiom
const expectedHostnames = new Set(
	(process.env.TURNSTILE_HOSTNAMES ?? '')
		.split(',')
		.map((h) => h.trim())
		.filter(Boolean),
);
if (expectedHostnames.size === 0) return res.status(403).end();

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 result = await r.json();
if (
	r.ok !== true ||
	result.success !== true ||
	result.action !== 'subscribe' ||
	!expectedHostnames.has(result.hostname)
) {
	return res.status(403).end();
}
// existing handler logic runs here
```

Equivalent calls in other backend languages (each also compares `result.hostname` to a `TURNSTILE_HOSTNAMES` allowlist):

```ruby
# Ruby
require 'net/http'; require 'uri'; require 'json'; require 'set'
expected_hostnames = (ENV['TURNSTILE_HOSTNAMES'] || '').split(',').map(&:strip).reject(&:empty?).to_set
halt 403 if expected_hostnames.empty?
res = Net::HTTP.post_form(URI('https://challenges.cloudflare.com/turnstile/v0/siteverify'),
  secret: ENV['TURNSTILE_SECRET'], response: params['cf-turnstile-response'], remoteip: request.ip)
result = JSON.parse(res.body)
halt 403 unless res.is_a?(Net::HTTPSuccess) && result['success'] == true && result['action'] == 'subscribe' && expected_hostnames.include?(result['hostname'])
```

```python
# Python (requests)
expected_hostnames = {h.strip() for h in os.environ.get('TURNSTILE_HOSTNAMES', '').split(',') if h.strip()}
if not expected_hostnames:
    return '', 403
r = requests.post('https://challenges.cloudflare.com/turnstile/v0/siteverify',
    data={'secret': os.environ['TURNSTILE_SECRET'],
          'response': form['cf-turnstile-response'],
          'remoteip': request.remote_addr})
result = r.json()
if (not r.ok or result.get('success') is not True or result.get('action') != 'subscribe'
        or result.get('hostname') not in expected_hostnames):
    return '', 403
```

`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`.

## Variant: AJAX submit instead of form action

For an AJAX flow, replace the native form and API script with explicit rendering. Keep this surface's widget ID and reset it in `finally`, which covers network, JSON, validation, and server failures as well as successful same-page completion.

```html
<form id="subscribe-form">
	<input name="email" type="email" required />
	<div id="subscribe-turnstile"></div>
	<button type="submit">Subscribe</button>
</form>
<script>
	let subscribeWidgetId;

	window.onSubscribeTurnstileLoad = () => {
		subscribeWidgetId = window.turnstile.render("#subscribe-turnstile", {
			sitekey: "YOUR_SITEKEY",
			action: "subscribe",
		});
	};

	document.getElementById("subscribe-form").addEventListener("submit", async (event) => {
		event.preventDefault();
		try {
			const res = await fetch("/api/subscribe", {
				method: "POST",
				body: new FormData(event.currentTarget),
			});
			const json = await res.json();
			if (!res.ok || json.ok !== true) throw new Error("Submission failed");
			// proceed
		} catch {
			// surface the error
		} finally {
			if (subscribeWidgetId !== undefined) {
				window.turnstile.reset(subscribeWidgetId);
			}
		}
	});
</script>
<script
	src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onSubscribeTurnstileLoad&render=explicit"
	async
	defer
></script>
```

## No backend?

If your project is pure-static (no server-side handler — just HTML served from a CDN), Spin doesn't apply. Siteverify is server-side by design. Options:

- Add a Cloudflare Pages Function (`functions/api/subscribe.js`) to host the siteverify call.
- Deploy a tiny Cloudflare Worker that does siteverify against your existing form host.
- Use a third-party form host that exposes a server-side webhook where you can wire siteverify.

## Substitutions

| Placeholder         | Replace with                                                         |
| ------------------- | -------------------------------------------------------------------- |
| `YOUR_SITEKEY`      | The widget site key from Step 8                                      |
| `/api/subscribe`    | The path to your existing form-handling endpoint                     |
| `TURNSTILE_SECRET`  | Env-var name. Value is the secret captured in Step 8, kept off disk. |

SHA-256: bb01ca94b0e942c0e329c2fcaf50d0c5ff846f1ae166aa6d654d4c962d48fa9d