Supabase Edge Functions 集成 Cloudflare Turnstile:从服务端校验到生产部署的完整实战
Cloudflare Turnstile 是 Cloudflare 推出的 CAPTCHA 替代方案,它用"隐形/交互式"的浏览器环境验证代替传统的人机验证码,而 Turnstile 的 token 校验必须放在服务端进行才能保证可信。本文以 supabase 官方仓库中 examples/edge-functions 下的 cloudflare-turnstile 示例为骨架,完整讲解如何在 Supabase Edge Functions(Deno 运行时)中实现 Turnstile 的 siteverify 服务端校验,并覆盖本地调试、verify_jwt 配置、密钥注入、前端调用与 curl 验证等全流程。读完你将能独立把一个防机器人验证能力接入自己的 Supabase 应用。
示例在仓库中的位置与整体架构
该示例是一个"最小可部署"的 Edge Function 实例,其文件构成非常清晰:
| 文件 | 作用 |
|---|---|
| examples/edge-functions/supabase/functions/cloudflare-turnstile/index.ts | Edge Function 服务端逻辑,负责调用 Cloudflare siteverify 接口完成 token 校验 |
| examples/edge-functions/supabase/functions/cloudflare-turnstile/README.md | 该示例的官方接入步骤说明 |
| examples/edge-functions/supabase/config.toml | 函数级配置,声明本函数为公开端点 |
| examples/edge-functions/supabase/.env.local.example | 本地环境变量样例,含 Turnstile 密钥占位 |
从部署与调用链路看,整条流程是:
- 前端(你的站点)通过 Cloudflare Turnstile 客户端渲染获得一个一次性
token(即cf-turnstile-response); - 前端把这个
tokenPOST 给 Supabase Edge Function(可经 examples/edge-functions/README.md 中提到的supabase-js的invoke方法或直接 HTTP 调用); - Edge Function 在服务端使用
CLOUDFLARE_TURNSTILE_SECRET_KEY调用 Cloudflare/siteverify接口换取校验结果; - 校验成功后函数把结果返回给前端,前端据此放行表单提交、登录等操作。
之所以把校验放到服务端,是因为 Turnstile 的 Site Key 在客户端是公开可见的,任何人都可以伪造请求绕过前端校验;只有配合 Secret Key 的服务端 siteverify 结果才是可信的验证依据。
前置准备:在 Cloudflare 侧创建站点与密钥
接入前需要先完成 Cloudflare 侧的两个准备动作(具体入口为 Cloudflare 控制台的 Turnstile 模块,本文不展开第三方站点细节):
- 添加新站点:为你的应用注册一个 Turnstile 站点,获得一对凭据——客户端
Site Key与服务端Secret Key。 - 客户端接入:在你自己的站点页面中引入 Turnstile 小组件(支持隐形模式或受管交互模式),配置好 Site Key,并在提交表单前拿到回调产生的
token(字段名通常为cf-turnstile-response)。
记住一个原则:Site Key 可以出现在浏览器端,Secret Key 只能存放在服务端环境变量中,这也是下面把它注入到 Supabase Secrets 的原因。
核心实现逐段拆解:index.ts 的服务端校验逻辑
示例的服务端实现只有一个文件 index.ts,逻辑相当精炼,值得逐段理解。
引入 withSupabase 并声明公开端点
import { withSupabase } from 'npm:@supabase/server@^1'
console.log(`Function "cloudflare-turnstile" up and running!`)
export default {
fetch: withSupabase({ auth: 'none' }, async (req, _ctx) => {
// ...
}),
}
代码注释里特别说明了两点:
- 公开端点,所以部署时要配置
verify_jwt = false。默认情况下 Supabase Edge Functions 会要求请求携带有效的 JWT(由 Supabase Auth 签发),而 Turnstile 校验需要先于用户登录发生(例如登录表单、注册表单前的人机验证),因此本函数关闭 JWT 校验,允许匿名访问。 withSupabase会自动处理 CORS,这解决了浏览器跨域调用函数的预检请求问题。{ auth: 'none' }表示此端点不解析或强制 Supabase Auth 身份。
需要说明的是:withSupabase 与 npm:@supabase/server@^1 是本仓库各示例(如 openai、browser-with-cors 等)广泛复用的服务端封装,它统一承担了 CORS、认证模式声明与响应封装等样板逻辑。
解析请求体中的 token
const { token } = await req.json()
if (!token) throw new Error('Missing token!')
前端 invoke 时传入的请求体是 { token },服务端先解构取出 token,一旦缺失立即抛出 Missing token! 错误,由下方的统一 catch 块转为 400 响应。这是一种廉价的"先验校验",避免携带空 token 去请求 Cloudflare。
获取客户端 IP 并组装 siteverify 请求
function ips(req: Request) {
return req.headers.get('x-forwarded-for')?.split(/\s*,\s*/)
}
const clientIps = ips(req) || ['']
const formData = new FormData()
formData.append('secret', Deno.env.get('CLOUDFLARE_TURNSTILE_SECRET_KEY') ?? '')
formData.append('response', token)
formData.append('remoteip', clientIps[0])
这里的细节值得注意:
x-forwarded-for是反向代理(如边缘网关)注入的标准请求头,可能包含一串以逗号分隔的 IP 链,因此代码按/\s*,\s*/拆分后取第一个 IP 作为客户端真实来源;remoteip是siteverify接口的可选参数,用于绑定本次校验对应的客户端 IP,帮助 Cloudflare 做更严格的关联判定;当拿不到请求头时退化为空字符串;- Secret Key 通过
Deno.env.get('CLOUDFLARE_TURNSTILE_SECRET_KEY')读取,即运行时的环境变量,这正是部署时必须用supabase secrets set注入的密钥。
调用 siteverify 并处理结果
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const result = await fetch(url, {
body: formData,
method: 'POST',
})
const outcome = await result.json()
console.log(outcome)
if (outcome.success) {
return Response.json(outcome)
}
throw new Error('Turnstile validation failed!')
- 校验入口是 Cloudflare 固定的
siteverify端点,请求体为标准的FormData,包含secret、response、remoteip三个字段; - 响应 JSON 中的
success布尔值即校验结论。示例把outcome打印到日志便于排查,success === true时原样返回给前端(其中可包含challenge_ts、hostname等元信息); - 校验失败则抛出
Turnstile validation failed!,落入下方 catch。
统一错误兜底
} catch (error) {
return Response.json({ error: error.message }, { status: 400 })
}
无论是缺少 token、网络请求异常还是 siteverify 校验失败,都会收敛为一个 400 响应,响应体为 { error: '...' }。这意味着前端可以依赖"HTTP 400 + error 字段"来做统一失败处理,同时避免把内部堆栈泄露给调用方。
本地 curl 冒烟测试
文件末尾还预置了一条本地调试命令,可用来在本地开发服务器上直接验证函数行为:
curl -i --location --request POST 'http://localhost:54321/functions/v1/cloudflare-turnstile' \
--header 'Content-Type: application/json' \
--data '{"token":"cf-turnstile-response"}'
把 "cf-turnstile-response" 替换成真实的小组件 token 即可。注意本地端口 54321 来自 examples/edge-functions/supabase/config.toml 中 [api] port = 54321 的默认配置;如果本机端口被占用,请以实际输出为准。
关闭 JWT 校验:config.toml 中的函数级配置
由于这是一个公开入口,examples/edge-functions/supabase/config.toml 中对它单独声明了:
[functions.cloudflare-turnstile]
verify_jwt = false
verify_jwt = false 表示该函数的 HTTP 端点不再要求请求携带 Supabase JWT。它是函数级别的开关,同一文件里其他函数(如需要鉴权的 functions.openai 的 verify_jwt = true)互不影响。如果你的 Turnstile 校验发生在用户登录之后(例如"已登录用户提交流表单"场景),可以尝试将其设为 true 并要求前端附带用户令牌,但本示例场景下保持 false 才是正确的。
本地开发运行
依据 examples/edge-functions/README.md 的说明,在仓库 examples/edge-functions 目录内可按如下顺序本地联调:
- 确保 Docker 守护进程已启动,运行
supabase start拉起本地 Supabase 全套服务; - 复制环境变量样例:
cp ./supabase/.env.local.example ./supabase/.env.local; - 在
.env.local中把CLOUDFLARE_TURNSTILE_SECRET_KEY=your_secret_key替换为你在 Cloudflare 控制台获得的真实 Secret Key(该占位符定义在 examples/edge-functions/supabase/.env.local.example 第 6 行附近); - 以本地模式启动函数服务:
supabase functions serve --env-file ./supabase/.env.local --no-verify-jwt--no-verify-jwt对应了 config.toml 中verify_jwt = false的本地形态,否则本地也会拦截未携带 JWT 的请求; - 用上文的内置 curl 命令或前端
invoke发请求验证。
此外,仓库还在 app/ 目录附带了一个 create-react-app 测试客户端,可当作 Postman 使用:cd app && npm install && npm start 即可在浏览器里向本地或已部署函数发送测试请求(本地测试时下拉框不生效,invoke 只会调用 CLI 当前正在服务的那个函数)。
部署到云端:deploy + secrets
部署分为两步,对应 README 中给出的两条命令:
supabase functions deploy cloudflare-turnstile
supabase secrets set CLOUDFLARE_TURNSTILE_SECRET_KEY=your_secret_key
- 第一条命令把本地
supabase/functions/cloudflare-turnstile目录编译并上传到你的 Supabase 项目; - 第二条命令把 Secret Key 作为环境变量写入云端运行时,
Deno.env.get('CLOUDFLARE_TURNSTILE_SECRET_KEY')在线上读取的就是这个值。
部署前通常还需要完成 CLI 登录与项目关联:生成 Personal Access Token 后执行 supabase login,再在项目目录内执行 supabase link --project-ref your-project-ref 关联远端项目。关于密钥管理,推荐生产环境使用独立的 .env 文件(与本地 .env.local 分开),然后通过 supabase secrets set --env-file ./path/to/prod.env 批量注入;之后可用 supabase secrets list 核对密钥是否写入成功以及查看默认注入的其他系统变量。
若你希望实现 CI/CD,仓库示例还支持通过 supabase/setup-cli 这类 GitHub Action 在推送时自动执行 supabase functions deploy,详见 examples/edge-functions/README.md 中的 workflow 示例。
前端站点如何调用
在客户端,使用 supabase-js 的 functions.invoke 即可把 Turnstile 小组件产生的 token 传给 Edge Function:
const { data, error } = await supabase.functions.invoke('cloudflare-turnstile', {
body: { token },
})
- 方法名
cloudflare-turnstile与函数目录名、部署名一一对应; - 请求体
{ token }与服务端req.json()解构的字段完全对齐; - 校验成功时
data为 Cloudflaresiteverify返回的outcome(含success: true);失败时error为函数的 400 响应体{ error: '...' }。
一个典型的使用时序是:用户在页面通过 Turnstile 验证 → 拿到 token → invoke 上述函数确认 data.success === true → 才把表单真正提交到后端业务接口(或进一步放入业务请求体中由后端再次校验),从而形成"客户端渲染 + 服务端校验"的完整防滥用闭环。
安全要点与最佳实践小结
从这份示例中可以提炼出几条可直接复用的经验:
- Secret Key 永不下发到前端:它只存在于服务端环境变量(本地
.env.local、云端 Secrets)中,浏览器拿不到; - 公开端点务必显式声明
verify_jwt = false,并理解它与"匿名可调用"的关系——校验函数本身应只做校验、不放行敏感操作,真正的业务鉴权仍在别处完成; - 善用
remoteip:将请求方真实 IP(取x-forwarded-for首项)随 siteverify 一起上报,可提高校验的严谨性,降低 token 被跨站重放的风险; - 统一 400 错误响应:把"缺 token / 校验失败 / 网络异常"都折叠为结构化错误体,让前后端错误契约清晰稳定;
- 借助运行时封装处理样板逻辑:
withSupabase已自动处理 CORS 与认证声明,编写这类公开验证函数时无需再手写跨域中间件; - 先本地后云端:利用
supabase functions serve --env-file与内置 curl 命令先做冒烟验证,再走 deploy + secrets 上线,可将大多数配置问题拦截在本地。
总结
在 supabase 官方仓库中,cloudflare-turnstile 示例以约 50 行的 index.ts 完整演示了一条现代 Web 应用防机器人验证的标准路径:客户端 Turnstile 组件产 token → 公开 Edge Function 服务端调 siteverify → 结构化响应回传前端。配合 config.toml 的 verify_jwt 开关、.env.local.example 的密钥占位以及 README 的部署命令,你可以把这套模式无缝迁移到登录、注册、评论、投票、抢购等任何需要拦截脚本刷量的场景。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00