The answer. Turnstile has two halves. The widget runs in the page and hands the form a token in cf-turnstile-response. The half that protects anything is server-side: the Worker POSTs that token and the site's secret to the siteverify endpoint and rejects the request unless the answer says success: true, the action matches what the page declared, and the hostname is one of yours. Each token is valid for five minutes and can be verified exactly once, so a replayed submission fails on its own. Three of our Workers (audit signup, guide delivery, beta register) run this same twelve-line check before touching D1 or sending mail.
The pattern.
async function verifyTurnstile(token: string, request: Request, env: Env): Promise<boolean> {
const body = new URLSearchParams({
secret: env.TURNSTILE_SECRET, response: token,
remoteip: request.headers.get("CF-Connecting-IP") ?? "",
});
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST", body, signal: AbortSignal.timeout(10_000),
});
if (!r.ok) return false; // fail closed
const v = await r.json<{ success: boolean; action?: string; hostname?: string }>();
return v.success && v.action === "signup" && env.ALLOWED_HOSTS.split(",").includes(v.hostname ?? "");
}
Watch out.
- The secret is a Worker secret (
wrangler secret put TURNSTILE_SECRET), never a var; the site key is public and fine in a var. - A page that stays open after submit must call
turnstile.reset(widgetId)before retrying; the old token is spent. - Test keys exist (always-pass, always-fail, always-block). Use them in staging so the widget never trains on your own QA.
Related: rate-limiting-binding-counts-per-key-in-the-worker · credential-free-edge-companion-hmac-site-tokens · queue-work-returns-202-client-polls-a-status-row