Guide
Cloudflare Turnstile contact form tutorial: verify the token in a Worker
Cloudflare Turnstile is a free CAPTCHA replacement. Most visitors never see a puzzle. But Turnstile on its own does not protect anything. The widget gives the browser a token, and your server has to check that token with Cloudflare. If nobody checks it, a bot can skip the widget and POST to your endpoint directly.
This tutorial builds the whole thing: an HTML form, a Cloudflare Worker that verifies the token, server-side validation, and a D1 table to store messages. It runs on the Workers free plan and works with any static host.
1. Create a Turnstile widget
In the Cloudflare dashboard, open Turnstile, add a widget, and enter your site’s hostname (add localhost too if you test locally). You get two values: a public site key that goes in the HTML, and a secret key that only the Worker should ever see. For automated tests, Cloudflare publishes dummy keys that always pass or always fail. Don’t use those in production.
2. The form
Load the Turnstile script and add a div with class cf-turnstile inside the form. When the challenge finishes, the widget adds a hidden input named cf-turnstile-response to the form, so a normal form submit carries it with no JavaScript from you.
<form id="contact" method="post" action="https://contact-form.YOUR-SUBDOMAIN.workers.dev/">
<label>Name <input name="name" required maxlength="80" autocomplete="name"></label>
<label>Email <input name="email" type="email" required maxlength="254" autocomplete="email"></label>
<label>Message <textarea name="message" required minlength="10" maxlength="5000"></textarea></label>
<!-- Honeypot: humans never see or fill this -->
<div style="position:absolute;left:-10000px" aria-hidden="true">
<input name="website" tabindex="-1" autocomplete="off">
</div>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>3. A table for messages
Save this as schema.sql:
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL
);4. The Worker
Configure the D1 binding and two plain variables in wrangler.toml:
name = "contact-form" main = "src/index.js" compatibility_date = "2026-11-01" [vars] ALLOWED_ORIGIN = "https://example.com" THANKS_URL = "https://example.com/thanks/" [[d1_databases]] binding = "DB" database_name = "contact-form" database_id = "PASTE_ID_FROM_d1_create"
Then write the handler. Checks run cheapest first. Nothing touches the database until the token is verified and the fields are valid.
// src/index.js
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// 1. Only accept posts from your own site.
const origin = request.headers.get("Origin");
if (origin !== env.ALLOWED_ORIGIN) {
return new Response("Forbidden", { status: 403 });
}
const form = await request.formData();
// 2. Honeypot: pretend success so bots learn nothing.
if (form.get("website")) {
return Response.redirect(env.THANKS_URL, 303);
}
// 3. Verify the Turnstile token on the server.
const token = form.get("cf-turnstile-response");
const ip = request.headers.get("CF-Connecting-IP");
const verify = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET,
response: token,
remoteip: ip,
}),
}
);
const outcome = await verify.json();
if (!outcome.success) {
return new Response("Verification failed. Go back and try again.", {
status: 400,
});
}
// 4. Validate on the server; browser "required" is only a hint.
const name = String(form.get("name") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const message = String(form.get("message") ?? "").trim();
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
if (!name || name.length > 80 || !emailOk || email.length > 254 ||
message.length < 10 || message.length > 5000) {
return new Response("Please check the form fields.", { status: 400 });
}
// 5. Store it.
await env.DB.prepare(
"INSERT INTO messages (name, email, message) VALUES (?, ?, ?)"
).bind(name, email, message).run();
return Response.redirect(env.THANKS_URL, 303);
},
};A few details that matter:
- Tokens are single-use and expire after 300 seconds. If someone leaves the tab open for ten minutes, Siteverify returns
timeout-or-duplicate. A friendly “go back and try again” message covers it. - Send
remoteipwhen you have it. It is optional but gives Cloudflare one more signal. - The origin check is not about security against curl. Anyone can fake an
Originheader. It stops other websites from embedding your form endpoint in their pages. Turnstile is what stops scripts. - Use a 303 redirect so a browser refresh on the thanks page doesn’t resubmit the form.
- If you want to be stricter, compare
outcome.hostnamewith your domain. This rejects tokens that were solved on a different site that uses the same key.
5. Deploy
npm create cloudflare@latest contact-form -- --type=hello-world --lang=js cd contact-form npx wrangler d1 create contact-form npx wrangler d1 execute contact-form --remote --file=./schema.sql npx wrangler secret put TURNSTILE_SECRET npx wrangler deploy
Paste the database_id from d1 create into wrangler.toml before you deploy, and put the secret key in when secret put asks for it. Then set the form’s action to the URL that wrangler deploy prints.
6. Read your messages
Without a dashboard, the CLI works fine:
npx wrangler d1 execute contact-form --remote \ --command "SELECT created_at, name, email, substr(message,1,80) FROM messages ORDER BY id DESC LIMIT 20"
What this version leaves out
This is a working minimum. It isn’t everything a production form needs. Before relying on it, think about:
- Rate limiting. Turnstile slows scripts down but doesn’t stop one human from pasting the same message 200 times. A per-IP counter in KV or the Workers rate limiting binding fixes that.
- A timing check. Reject submissions that arrive less than a second or two after the page loads.
- Notifications. A D1 row nobody looks at is a lost lead. Send yourself an email (Resend, Postmark, or similar) or a Discord or Slack webhook after the insert succeeds, ideally inside
ctx.waitUntil. - An admin view with search, CSV export, and delete, so you’re not living in
wrangler d1 execute. - A fetch-based submit that shows errors inline instead of sending people to a plain-text error page.
Those are the parts that turn a tutorial into something you can hand to a client. Formharbor is a $19 kit with all of them built in: Turnstile Siteverify with hostname checks, honeypot, timing trap, origin allowlist, per-IP rate limits, email and webhook alerts, a password-protected dashboard with CSV export, plus a referral waitlist and R2 file uploads on the same Worker. If you’d rather build those yourself, the code above is a solid starting point.