Enter fullscreen mode Exit fullscreen mode 3. 创建留言数据表 将下面内容保存为 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 ); Enter fullscreen mode Exit fullscreen mode 4. 编写 WORKER 在 wrangler.toml 中配置 D1 绑定和两个普通变量: 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\" Enter fullscreen mode Exit fullscreen mode 接下来编写处理函数。校验顺序要讲究:先执行成本最低的检查。令牌没有通过验证、字段也不合法时,不应该碰数据库。 // 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); }, }; Enter fullscreen mode Exit fullscreen mode 有几个细节不能忽略: * 令牌只能使用一次,并且 300 秒后过期。如果用户把页面开了十分钟再提交,Siteverify 会返回 timeout-or-duplicate。页面给出“返回后重新提交”的提示即可。 * 能拿到客户端 IP 时,应当发送 remoteip。这个字段是可选的,但可以给 Cloudflare 多提供一层判断依据。 * Origin 检查并不能抵御 curl。任何人都能伪造 Origin 请求头。这个检查主要是防止其他网站把你的表单接口嵌入自己的页面,真正用于阻止脚本的是 Turnstile。 * 使用 303 重定向。这样用户在感谢页刷新浏览器时,不会再次提交表单。 * 如果需要更严格,可以把 outcome.hostname 与自己的域名进行比较。这样可以拒绝那些在其他站点上使用相同密钥完成的令牌。 5. 部署 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 Enter fullscreen mode", "datePublished": "2026-11-13 14:49:06", "author": null, "articleSection": "前端技术", "isPartOf": { "@type": "Blog", "name": "Marico's space", "url": "https://marico.cc" } }

最近折腾 Cloudflare Turnstile 联系表单,踩了几个坑:令牌不校验等于没加防护、密钥放错位置、请求顺序没安排好。这篇把完整流程说清楚,包括表单、Worker(服务端脚本)验证、D1 数据库和部署。
Cloudflare Turnstile 是一款免费的 CAPTCHA(验证码)替代方案。大多数访客甚至看不到拼图或图片验证。但要注意,单独接入 Turnstile 不会自动保护任何接口。
组件只会给浏览器发一个令牌,真正的校验必须由服务端拿令牌向 Cloudflare 验证。如果服务端不检查,机器人完全可以绕过组件,直接向你的接口发送 POST 请求。
下面这套方案包含 HTML 表单、用于验证令牌的 Cloudflare Worker、服务端字段校验,以及用于保存留言的 D1 数据库。整套服务运行在 Workers 免费套餐上,表单可以部署在任何静态托管平台上。
Formharbor 网站上也有一份原始版本:Cloudflare Turnstile contact form tutorial。
进入 Cloudflare 控制台,打开 Turnstile,添加一个组件,然后填写站点域名。如果要在本地测试,也要把 localhost 加进去。
创建完成后会得到两个值:一个公开的 site key,可以直接写进 HTML;另一个是 secret key,只能保存在服务端,不能暴露给浏览器,更不能直接写进前端代码。
Cloudflare 为自动化测试提供了一组始终通过或始终失败的 dummy keys(测试密钥)。这些密钥只能用于测试,不能放进生产环境。
加载 Turnstile 脚本,然后在表单中加入一个类名为 cf-turnstile 的 div。挑战完成后,组件会自动向表单添加一个名为 cf-turnstile-response 的隐藏输入框。
因此,正常提交表单时,令牌会自动一起提交,不需要自己编写额外的 JavaScript。
<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>
将下面内容保存为 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
);
在 wrangler.toml 中配置 D1 绑定和两个普通变量:
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"
接下来编写处理函数。校验顺序要讲究:先执行成本最低的检查。令牌没有通过验证、字段也不合法时,不应该碰数据库。
// 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); },
};
有几个细节不能忽略:
timeout-or-duplicate。页面给出“返回后重新提交”的提示即可。remoteip。这个字段是可选的,但可以给 Cloudflare 多提供一层判断依据。Origin 请求头。这个检查主要是防止其他网站把你的表单接口嵌入自己的页面,真正用于阻止脚本的是 Turnstile。outcome.hostname 与自己的域名进行比较。这样可以拒绝那些在其他站点上使用相同密钥完成的令牌。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