首页
/ Supabase Edge Functions 集成 Cloudflare Turnstile:从服务端校验到生产部署的完整实战

Supabase Edge Functions 集成 Cloudflare Turnstile:从服务端校验到生产部署的完整实战

2026-09-06 18:07:03作者:段琳惟

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 密钥占位

从部署与调用链路看,整条流程是:

  1. 前端(你的站点)通过 Cloudflare Turnstile 客户端渲染获得一个一次性 token(即 cf-turnstile-response);
  2. 前端把这个 token POST 给 Supabase Edge Function(可经 examples/edge-functions/README.md 中提到的 supabase-jsinvoke 方法或直接 HTTP 调用);
  3. Edge Function 在服务端使用 CLOUDFLARE_TURNSTILE_SECRET_KEY 调用 Cloudflare /siteverify 接口换取校验结果;
  4. 校验成功后函数把结果返回给前端,前端据此放行表单提交、登录等操作。

之所以把校验放到服务端,是因为 Turnstile 的 Site Key 在客户端是公开可见的,任何人都可以伪造请求绕过前端校验;只有配合 Secret Key 的服务端 siteverify 结果才是可信的验证依据。

前置准备:在 Cloudflare 侧创建站点与密钥

接入前需要先完成 Cloudflare 侧的两个准备动作(具体入口为 Cloudflare 控制台的 Turnstile 模块,本文不展开第三方站点细节):

  1. 添加新站点:为你的应用注册一个 Turnstile 站点,获得一对凭据——客户端 Site Key 与服务端 Secret Key
  2. 客户端接入:在你自己的站点页面中引入 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 身份。

需要说明的是:withSupabasenpm:@supabase/server@^1 是本仓库各示例(如 openaibrowser-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 作为客户端真实来源;
  • remoteipsiteverify 接口的可选参数,用于绑定本次校验对应的客户端 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,包含 secretresponseremoteip 三个字段;
  • 响应 JSON 中的 success 布尔值即校验结论。示例把 outcome 打印到日志便于排查,success === true 时原样返回给前端(其中可包含 challenge_tshostname 等元信息);
  • 校验失败则抛出 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.openaiverify_jwt = true)互不影响。如果你的 Turnstile 校验发生在用户登录之后(例如"已登录用户提交流表单"场景),可以尝试将其设为 true 并要求前端附带用户令牌,但本示例场景下保持 false 才是正确的

本地开发运行

依据 examples/edge-functions/README.md 的说明,在仓库 examples/edge-functions 目录内可按如下顺序本地联调:

  1. 确保 Docker 守护进程已启动,运行 supabase start 拉起本地 Supabase 全套服务;
  2. 复制环境变量样例:cp ./supabase/.env.local.example ./supabase/.env.local
  3. .env.local 中把 CLOUDFLARE_TURNSTILE_SECRET_KEY=your_secret_key 替换为你在 Cloudflare 控制台获得的真实 Secret Key(该占位符定义在 examples/edge-functions/supabase/.env.local.example 第 6 行附近);
  4. 以本地模式启动函数服务:
    supabase functions serve --env-file ./supabase/.env.local --no-verify-jwt
    
    --no-verify-jwt 对应了 config.toml 中 verify_jwt = false 的本地形态,否则本地也会拦截未携带 JWT 的请求;
  5. 用上文的内置 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-jsfunctions.invoke 即可把 Turnstile 小组件产生的 token 传给 Edge Function:

const { data, error } = await supabase.functions.invoke('cloudflare-turnstile', {
  body: { token },
})
  • 方法名 cloudflare-turnstile 与函数目录名、部署名一一对应;
  • 请求体 { token } 与服务端 req.json() 解构的字段完全对齐;
  • 校验成功时 data 为 Cloudflare siteverify 返回的 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.tomlverify_jwt 开关、.env.local.example 的密钥占位以及 README 的部署命令,你可以把这套模式无缝迁移到登录、注册、评论、投票、抢购等任何需要拦截脚本刷量的场景。

登录后查看全文
热门项目推荐
相关项目推荐