Cloudflare Turnstile 无感人机验证实战指南:从隐式渲染到 Workers 服务端校验
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
本文是 cloudflare-deploy Skill 中 Turnstile 参考模块(turnstile/README.md)的完整展开:它面向在 Cloudflare 平台(Workers / Pages)上构建应用时需防护机器人流量的场景,系统讲解 Turnstile 的三种 Widget 类型、隐式/显式渲染两种接入方式、服务端 siteverify 校验、完整配置项与 JavaScript API。读完本文,你将能够在自己的站点/表单中落地一套"无需用户点击、后台自动完成"的人机验证方案,并具备排查令牌过期、CSP 拦截、密钥泄漏等常见问题的能力。
一、Turnstile 是什么:无感 CAPTCHA 的运作方式
Turnstile 是 Cloudflare 提供的智能验证码替代方案,核心设计目标是:在后台运行验证挑战,全程不需要用户交互。它不再要求用户识别扭曲文字、勾选图片或做数学题,而是通过浏览器行为信号、设备指纹以及机器学习模型自动判定访客是否为真人。
从该参考模块的定位看,Turnstile 属于 cloudflare-deploy 安全产品线中与 WAF、DDoS、Bot Management、API Shield 并列的"CAPTCHA 替代"选项(见 SKILL.md 安全决策树)。与 Bot Management 这类面向网络层的机器人管理不同,Turnstile 以"验证码"的形式嵌入到具体的表单、登录页或 API 调用流程中,二者可组合使用。
Widget 三种类型对比
Turnstile 提供三种 Widget 模式,按用户可见性与交互方式区分,适用场景也不同:
| 类型 | 交互方式 | 适用场景 |
|---|---|---|
| Managed(默认) | 仅在需要时显示复选框 | 表单、登录页——在用户体验与安全性之间取得平衡 |
| Non-Interactive | 不可见,自动运行 | 低风险操作,追求零摩擦体验 |
| Invisible | 隐藏,由程序触发 | 预放行(Pre-clearance)、API 调用、无头环境 |
其中 Managed 模式最接近传统 reCAPTCHA v2 的"复选框"体验,但默认情况下大多数真人用户甚至看不到任何验证界面;Non-Interactive 模式对用户完全透明;Invisible 模式则适合在页面加载阶段提前完成验证、把生成的令牌缓存下来供后续表单或 API 请求使用(对应下文"预放行"模式)。
二、快速上手:三步完成最小可运行接入
1. 隐式渲染(纯 HTML 方式)
隐式渲染只需两步:加载官方脚本,然后在表单中放置一个带cf-turnstile类名的容器元素,脚本加载完成后会自动完成渲染:
<!-- 1. 加载脚本 --> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <!-- 2. 在表单中放置 widget --> <form action="/submit" method="POST"> <div class="cf-turnstile"><div id="turnstile-container"></div> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script> <script> window.turnstile.render('#turnstile-container', { sitekey: 'YOUR_SITE_KEY', callback: (token) => console.log('Token:', token) }); </script>render()的容器参数既可以是 CSS 选择器字符串,也可以是 DOM 元素;返回值是 widgetId 字符串,后续的reset()、remove()、getResponse()等 API 都依赖它。
3. 服务端校验(必需步骤)
任何客户端校验都不足以构成安全边界,令牌必须由服务端调用 siteverify 接口确认。以下是 Cloudflare Workers 中的标准实现:
// Cloudflare Workers export default { async fetch(request) { const formData = await request.formData(); const token = formData.get('cf-turnstile-response'); const result = 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: request.headers.get('CF-Connecting-IP') // 可选但推荐 }) }); const validation = await result.json(); if (!validation.success) { return new Response('Invalid CAPTCHA', { status: 400 }); } // Process form... } }env.TURNSTILE_SECRET是配置在 Worker 上的 Secret 绑定,绝不能暴露到客户端代码中。remoteip传用户真实 IP(Workers 中取CF-Connecting-IP请求头)有助于 Cloudflare 侧的风控判断。
三、脚本加载的四种方式与兼容模式
Turnstile 官方脚本https://challenges.cloudflare.com/turnstile/v0/api.js支持通过 URL 查询参数切换行为(详见 configuration.md):
| 加载方式 | 脚本 URL | 行为 |
|---|---|---|
| 基础(隐式渲染) | api.js(可加async defer) | 页面加载时自动渲染所有class="cf-turnstile"的容器 |
| 显式渲染 | api.js?render=explicit | 关闭自动渲染,完全由window.turnstile.render()控制 |
| 加载回调 | api.js?onload=myCallback | 脚本就绪后调用指定全局函数,在回调内执行渲染 |
| 兼容模式 | api.js?compat=recaptcha | 暴露grecaptchaAPI,可作为 Google reCAPTCHA 的平替接入 |
加载回调的典型用法:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=myCallback"></script> <script> function myCallback() { // API 已就绪 window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' }); } </script>也可以使用约定的window.onloadTurnstileCallback全局函数名(见 api.md):
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback"></script> <script> window.onloadTurnstileCallback = () => { window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' }); }; </script>四、Widget 配置项全解
完整配置对象
显式渲染时通过render()的第二个参数传入配置对象;隐式渲染时则通过 HTML 的data-*属性(见下文映射表)。完整选项如下:
{ // 必填 sitekey: 'YOUR_SITE_KEY', // 从 Dashboard 获取的站点密钥 // 回调 callback: (token) => {}, // 校验成功,令牌已就绪 'error-callback': (code) => {}, // 发生错误,code 为错误码 'expired-callback': () => {}, // 令牌过期(超过 5 分钟) 'timeout-callback': () => {}, // 验证挑战超时 'before-interactive-callback': () => {}, // 显示复选框之前触发 'after-interactive-callback': () => {}, // 用户交互之后触发 'unsupported-callback': () => {}, // 浏览器不支持 Turnstile 时触发 // 外观 theme: 'auto', // 'light' | 'dark' | 'auto' size: 'normal', // 'normal' | 'compact' | 'flexible' tabindex: 0, // Tab 键顺序(无障碍支持) language: 'auto', // ISO 639-1 语言码或 'auto' // 行为 execution: 'render', // 'render'(默认,渲染即开始挑战)| 'execute'(等待手动触发) appearance: 'always', // 'always' | 'execute' | 'interaction-only' retry: 'auto', // 'auto' | 'never' 'retry-interval': 8000, // 重试间隔(毫秒),默认 8000 'refresh-expired': 'auto', // 'auto' | 'manual' | 'never' // 表单集成 'response-field': true, // 是否注入隐藏输入域(默认 true) 'response-field-name': 'cf-turnstile-response', // 隐藏输入域的名称 // 分析与数据 action: 'login', // 行为名称(用于分析统计) cData: 'user-session-123', // 自定义数据(siteverify 响应中原样返回) }关键行为选项说明
execution(挑战触发时机)
'render'(默认):渲染后立即开始挑战;'execute':等待应用调用turnstile.execute()才启动挑战,适合"提交按钮点击后才验证"的场景。
appearance(可见性)
'always'(默认):Widget 始终可见;'execute':隐藏,直到调用execute()后才显示;'interaction-only':隐藏,直到需要用户交互时才出现。
refresh-expired(令牌过期后的处理策略)
'auto'(默认):自动刷新过期令牌;'manual':应用必须在expired-callback中手动调用reset();'never':不刷新,直接触发expired-callback。
retry(挑战失败重试)
'auto'(默认):自动重试失败的挑战;'never':不重试,直接触发error-callback。
HTML data 属性映射
隐式渲染下,所有配置项都可以在<div class="cf-turnstile">上用data-*属性表达:
| JavaScript 属性 | HTML data 属性 | 示例 |
|---|---|---|
sitekey | data-sitekey | data-sitekey="YOUR_KEY" |
action | data-action | data-action="login" |
cData | data-cdata | data-cdata="session-123" |
callback | data-callback | data-callback="onSuccess" |
error-callback | data-error-callback | data-error-callback="onError" |
expired-callback | data-expired-callback | data-expired-callback="onExpired" |
timeout-callback | data-timeout-callback | data-timeout-callback="onTimeout" |
theme | data-theme | data-theme="dark" |
size | data-size | data-size="compact" |
tabindex | data-tabindex | data-tabindex="0" |
response-field | data-response-field | data-response-field="false" |
response-field-name | data-response-field-name | data-response-field-name="token" |
retry | data-retry | data-retry="never" |
retry-interval | data-retry-interval | data-retry-interval="5000" |
language | data-language | data-language="en" |
execution | data-execution | data-execution="execute" |
appearance | data-appearance | data-appearance="interaction-only" |
refresh-expired | data-refresh-expired | data-refresh-expired="manual" |
综合示例:
<div class="cf-turnstile" ><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com;">六、框架集成方案
React
npm install @marsidev/react-turnstileimport Turnstile from '@marsidev/react-turnstile'; <Turnstile siteKey="YOUR_SITE_KEY" onSuccess={(token) => console.log(token)} />Vue
npm install vue-turnstile<template> <VueTurnstile site-key="YOUR_SITE_KEY" @success="onSuccess" /> </template> <script setup> import VueTurnstile from 'vue-turnstile'; </script>Svelte
npm install svelte-turnstile<script> import Turnstile from 'svelte-turnstile'; </script> <Turnstile siteKey="YOUR_SITE_KEY" on:turnstile-callback={handleToken} />Next.js(App Router)
由于window.turnstile只在客户端存在,组件必须标记为'use client',并在useEffect中渲染、在清理函数中移除,避免 SSR 水合问题:
// app/components/TurnstileWidget.tsx 'use client'; import { useEffect, useRef } from 'react'; export default function TurnstileWidget({ sitekey, onSuccess }) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { if (ref.current && window.turnstile) { const widgetId = window.turnstile.render(ref.current, { sitekey, callback: onSuccess }); return () => window.turnstile.remove(widgetId); } }, [sitekey, onSuccess]); return <div ref={ref} />; }Cloudflare Pages 插件
若站点托管在 Cloudflare Pages,可用官方插件在 Functions 层统一拦截校验,无需手写 siteverify 逻辑:
npm install @cloudflare/pages-plugin-turnstile// functions/_middleware.ts import turnstilePlugin from '@cloudflare/pages-plugin-turnstile'; export const onRequest = turnstilePlugin({ secret: 'YOUR_SECRET_KEY', onError: () => new Response('CAPTCHA failed', { status: 403 }) });七、客户端 JavaScript API 参考
脚本加载完成后,API 暴露在window.turnstile上(完整接口见 api.md):
| 方法 | 签名 | 说明 |
|---|---|---|
render | render(container, options): string | 渲染 Widget,返回 widgetId |
reset | reset(widgetId): void | 重置 Widget(清除令牌与挑战状态),表单校验失败时常用 |
remove | remove(widgetId): void | 将 Widget 从 DOM 中彻底移除 |
getResponse | getResponse(widgetId): string \| undefined | 获取当前令牌,未就绪时返回 undefined |
isExpired | isExpired(widgetId): boolean | 判断令牌是否已过期(超过 5 分钟) |
execute | execute(container?, options?): void | 手动触发挑战(配合execution: 'execute') |
典型用法:
// 渲染 const widgetId = window.turnstile.render('#my-container', { sitekey: 'YOUR_SITE_KEY', callback: (token) => console.log('Success:', token), 'error-callback': (code) => console.error('Error:', code) }); // 表单校验失败时重置 if (!validateForm()) { window.turnstile.reset(widgetId); } // 导航时清理 window.turnstile.remove(widgetId); // 提交前读取令牌 const token = window.turnstile.getResponse(widgetId); if (token) { submitForm(token); } // 过期检查 if (window.turnstile.isExpired(widgetId)) { window.turnstile.reset(widgetId); }回调函数签名(TypeScript):
type TurnstileCallback = (token: string) => void; type ErrorCallback = (errorCode: string) => void; type TimeoutCallback = () => void; type ExpiredCallback = () => void; type BeforeInteractiveCallback = () => void; type AfterInteractiveCallback = () => void; type UnsupportedCallback = () => void;window.turnstile的完整 TypeScript 声明还包含onloadTurnstileCallback的全局类型扩展,方便在 TS 项目中直接使用。
八、Siteverify 服务端校验 API
端点:https://challenges.cloudflare.com/turnstile/v0/siteverify方法:POST,Content-Type支持application/json或application/x-www-form-urlencoded
请求结构
interface SiteverifyRequest { secret: string; // 你的密钥,绝不能暴露在客户端 response: string; // 来自 cf-turnstile-response 的令牌 remoteip?: string; // 用户 IP(可选但推荐) idempotency_key?: string; // 幂等校验用的唯一键 }响应结构
interface SiteverifyResponse { success: boolean; // 校验结果 challenge_ts?: string; // 挑战完成时的 ISO 时间戳 hostname?: string; // 完成 Widget 挑战的主机名 'error-codes'?: string[]; // success 为 false 时的错误码 action?: string; // Widget 配置中的 action 名 cdata?: string; // Widget 配置中的自定义数据 }成功响应示例:
{ "success": true, "challenge_ts": "2024-01-15T10:30:00Z", "hostname": "example.com", "action": "login", "cdata": "user123" }失败响应示例:
{ "success": false, "error-codes": ["timeout-or-duplicate"] }利用响应中的action与cdata,服务端还可以进一步核对本次验证对应的业务上下文(例如期望的 action 是否为login),实现更细粒度的校验。
错误码速查
| 错误码 | 原因 | 解决办法 |
|---|---|---|
missing-input-secret | 未提供密钥 | 在请求中带上secret |
invalid-input-secret | 密钥错误 | 到 Dashboard 核对密钥 |
missing-input-response | 未提供令牌 | 请求中带上response令牌 |
invalid-input-response | 令牌无效或格式错误 | 确认令牌来自 Widget |
timeout-or-duplicate | 令牌过期(>5 分钟)或已被复用 | 生成新令牌,且令牌只能校验一次 |
internal-error | Cloudflare 服务端错误 | 指数退避重试 |
bad-request | 请求格式错误 | 检查 JSON / 表单编码 |
九、测试密钥:开发期必备
Cloudflare 为开发与测试提供了固定的测试密钥(见 README.md 的 Testing Keys 一节):
| 类型 | 密钥 | 行为 |
|---|---|---|
| Site Key(始终通过) | 1x00000000000000000000AA | Widget 成功,令牌可正常校验 |
| Site Key(始终拦截) | 2x00000000000000000000AB | Widget 可见地失败 |
| Site Key(强制挑战) | 3x00000000000000000000FF | 始终显示交互式挑战 |
| Secret Key(测试) | 1x0000000000000000000000000000000AA | 可校验测试令牌 |
注意:测试密钥可在localhost及任意域名上工作,切勿用于生产环境。
推荐按环境切换密钥,避免测试密钥泄漏到线上:
const SITE_KEY = process.env.NODE_ENV === 'production' ? 'YOUR_PRODUCTION_SITE_KEY' : '1x00000000000000000000AA'; // 始终通过 const SECRET_KEY = process.env.NODE_ENV === 'production' ? process.env.TURNSTILE_SECRET : '1x0000000000000000000000000000000AA';十、核心约束:必须遵守的三条红线
来自 README.md 的 Key Constraints,是上线前必须逐条核对的设计前提:
- 令牌有效期 5 分钟:令牌生成后 5 分钟即失效,页面停留过久会导致提交时校验失败;
- 令牌单次使用:每个令牌只能成功校验一次,重复校验会返回
timeout-or-duplicate; - 必须服务端校验:仅靠客户端检查很容易被绕过,服务端 siteverify 是不可省略的安全边界。
同时可参考 gotchas.md 中的限制速查:
| 限制项 | 值 | 影响 |
|---|---|---|
| 令牌有效期 | 5 分钟 | 过期后必须重新生成 |
| 令牌使用次数 | 单次 | 同一令牌不能重复校验 |
| Widget 尺寸 | 300x65px(normal)、130x120px(compact) | 布局时需要预留空间 |
十一、常见集成模式
基本表单(隐式渲染)
<!DOCTYPE html> <html> <head> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> </head> <body> <form action="/submit" method="POST"> <input type="email" name="email" required> <div class="cf-turnstile"><script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script> <script> let widgetId = window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY', callback: (token) => console.log('Token:', token) }); form.addEventListener('submit', async (e) => { e.preventDefault(); const token = window.turnstile.getResponse(widgetId); if (!token) return; const response = await fetch('/submit', { method: 'POST', body: JSON.stringify({ 'cf-turnstile-response': token }) }); if (!response.ok) window.turnstile.reset(widgetId); }); </script>Workers 服务端校验(完整版)
interface Env { TURNSTILE_SECRET: string; } export default { async fetch(request: Request, env: Env): Promise<Response> { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }); } const formData = await request.formData(); const token = formData.get('cf-turnstile-response'); if (!token) { return new Response('Missing token', { status: 400 }); } // 校验令牌 const ip = request.headers.get('CF-Connecting-IP'); const result = 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 validation = await result.json(); if (!validation.success) { return new Response('CAPTCHA validation failed', { status: 403 }); } // 处理表单... return new Response('Success'); } };Pages Functions 版本
与 Workers 模式相同,使用ctx.env与ctx.request:
// functions/submit.ts export const onRequestPost: PagesFunction<{ TURNSTILE_SECRET: string }> = async (ctx) => { const token = (await ctx.request.formData()).get('cf-turnstile-response'); // 用 ctx.env.TURNSTILE_SECRET 校验(与 Workers 模式一致) };预放行模式(Invisible + 缓存令牌)
适合"提前验证、提交时直接用"的流程:页面加载时用 Invisible Widget 静默获取令牌,拿到令牌后再显示受保护的表单:
<div id="turnstile-precheck"></div> <form id="protected-form" style="display: none;"> <button type="submit">Submit</button> </form> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script> <script> let cachedToken = null; window.onload = () => { window.turnstile.render('#turnstile-precheck', { sitekey: 'YOUR_SITE_KEY', size: 'invisible', callback: (token) => { cachedToken = token; document.getElementById('protected-form').style.display = 'block'; } }); }; </script>令牌过期自动刷新
let widgetId = window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY', 'refresh-expired': 'manual', 'expired-callback': () => { console.log('Token expired, refreshing...'); window.turnstile.reset(widgetId); } });十二、常见问题与排查清单
四条铁律(最容易踩的坑)
- ❌ 跳过服务端校验:仅客户端校验极易被绕过,必须在服务端调用 siteverify。
- ❌ 密钥泄漏到客户端:
secret只能存在于服务端(环境变量 / Secret 绑定),绝不能写入前端代码。 - ❌ 复用令牌:令牌单次有效,重复校验会报
timeout-or-duplicate。每次提交应使用新令牌,出错时reset(widgetId)后重试。 - ❌ 忽略令牌过期:令牌 5 分钟过期,需处理
expired-callback或启用refresh-expired: 'auto'。
常见错误速查
| 错误现象 | 原因 | 解决办法 |
|---|---|---|
| Widget 不渲染 | sitekey 错误、CSP 拦截、file://协议 | 核对 sitekey;为 challenges.cloudflare.com 配置 CSP;用http://访问 |
timeout-or-duplicate | 令牌过期(>5 分钟)或复用 | 生成新令牌,令牌缓存不要超过 5 分钟 |
invalid-input-secret | 密钥错误 | 从 Dashboard 核对密钥与环境变量 |
missing-input-response | 令牌未随请求发送 | 检查表单字段名是否为cf-turnstile-response |
框架级陷阱
- React:Widget 重挂载:状态变化导致组件重渲染时会丢失令牌。解决:用
useRef保存 widgetId,仅渲染一次,卸载时remove()。 - React StrictMode:双重渲染:开发模式下 Widget 会渲染两次。解决:
useEffect的清理函数中调用turnstile.remove()。 - Next.js:SSR 水合:服务端渲染时
window.turnstile不存在。解决:组件加'use client',或动态导入并设ssr: false。 - SPA:导航不清理:切换路由会残留孤儿 Widget。解决:在卸载钩子中移除(Vue
onBeforeUnmount、ReactuseEffect清理函数)。
网络与安全
- CSP 拦截:按第五节添加
script-src与frame-src指令。 - IP 转发:确保拿到的是客户端真实 IP——Workers 中取
CF-Connecting-IP;自建代理场景取X-Forwarded-For的第一项:request.headers.get('X-Forwarded-For')?.split(',')[0]。 - CORS(siteverify):禁止从浏览器直接调用 siteverify(会触发 CORS 且暴露密钥)。正确做法是:浏览器 → 你的后端 → siteverify。
调试三板斧
- 控制台日志:把各回调都打出来,观察令牌/错误/过期事件流:
window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY', callback: (token) => console.log('✓ Token:', token), 'error-callback': (code) => console.error('✗ Error:', code), 'expired-callback': () => console.warn('⏱ Expired'), 'timeout-callback': () => console.warn('⏱ Timeout') });- 检查令牌状态:
const token = window.turnstile.getResponse(widgetId); console.log('Token:', token || 'NOT READY'); console.log('Expired:', window.turnstile.isExpired(widgetId));- Network 面板:确认
api.js返回 200、检查 siteverify 请求/响应、留意 4xx/5xx。
常见配置错误
- 密钥配对错误:sitekey 与 secret 必须来自 Dashboard 中的同一个 Widget;
- 测试密钥用于生产:务必用环境变量按环境切换密钥;
- 缺少环境变量:服务端
TURNSTILE_SECRET未定义会导致校验直接失败。可在.env配置后用console.log('Secret loaded:', !!process.env.TURNSTILE_SECRET)验证加载情况。
十三、深入阅读
Turnstile 参考模块由五份文档组成,本文是对入口文档的完整展开,按以下顺序阅读可获得从配置到原理的完整链路:
- configuration.md —— 环境配置、Widget 选项、脚本加载方式;
- api.md —— 客户端 JavaScript API、siteverify 端点、TypeScript 类型;
- patterns.md —— 表单集成、框架示例、校验模式;
- gotchas.md —— 常见错误、调试技巧与限制说明。
若需要把 Turnstile 与 WAF、Bot Management、API Shield 等其他安全产品组合成整体防护方案,可回到 cloudflare-deploy Skill 的安全决策树按需选择对应参考模块。
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考