Subchapter 11.6
references/vanilla-html.mdMarkdown6 KBView on GitHub
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.).
<!doctype html>
<html
When the form submits, the browser includes cf-turnstile-response automatically. Your backend reads it and calls canonical siteverify.
Add this to your existing /api/subscribe handler before the rest of its logic:
// 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 hereEquivalent calls in other backend languages (each also compares result.hostname to a TURNSTILE_HOSTNAMES allowlist):
# 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 (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 '', 403subscribe 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 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.
<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>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:
functions/api/subscribe.js) to host the siteverify call.| 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. |