你好,我是一卒。
因为我们的网站里面有调用 API,而调用 API 我们是要花钱的。所以为了避免被别人用机器人注册账号来薅羊毛,我们需要给我们的网站增加一个防护措施。
文章里面涉及到一些代码,在电脑上阅读体验更好。

这个防护措施就是给网站增加一个功能,来检查访问的是真人还是机器人脚本。
这个功能就是人机验证。人机验证这种方式有多种,这里我们选择 Cloudflare Turnstile。
Cloudflare 是一家美国互联网基础设施与网络安全公司,我们上篇文章提到的 DNS 域名解析就是这家公司的强项。Cloudflare 提供免费的 DNS 服务,而且安全性很高
Turnstile 直译过来是“旋转闸门”的意思,Cloudflare Turnstile 就像在这个闸门口放了一个保安,这个保安会验证来访问的是真人还是机器人
Cloudflare Turnstile 人机验证的效果是这样的:

一、选择 Cloudflare Turnstile 的理由
- 完全免费,没有请求量限制
- 用户体验好:不像谷歌的 reCAPTCHA 人机验证,要点红绿灯,自行车等,很麻烦

接入简单:前端加个组件,后端加个验证接口
Cloudflare Turnstile 有三种模式,分别是Managed托管模式、Non-Interactive非交互模式、Invisible 隐身模式
直接选择托管模式即可
托管模式的意思是,Cloudflare 会根据访问者的风险等级来决定如何验证:
- 如果 Cloudflare 觉得是真人,就直接放行,用户什么都看不到。
- 如果 Cloudflare 觉得有可能是真人,也可能不是真人,那就会在页面上出现一个小方块,让用户点击一下。

如果 Cloudflare 觉得是机器人,则会直接验证失败。
二、如何配置 Cloudflare Turnstile 人机验证?
网址:https://www.cloudflare.com/

- 点击 Turnstile

- 点击 Add widget 添加组件,添加域名
![]() | ![]() |
- 选择Managed托管模式,点击创建

- 复制站点密钥和私密密钥

这两个密码不要泄露,要在后端添加到环境变量里使用。
Site Key 泄露:几乎没有风险
Secret Key 泄露:风险很大
它的作用是让你的后端服务器去 Cloudflare 验证"用户提交的 token 是不是真的"。如果它泄露了,
人机验证直接失效。
任何人拿到你的 Secret Key,就可以自己写代码,伪造一个"验证通过"的响应发给你的后端。你的后端以为验证通过了,实际上根本没有经过 Turnstile 检查。机器人就可以畅通无阻地注册账号、薅你的 AI 额度,就像没加人机验证一样。之前做的所有防护都白费了。
- 把两个key保存在环境变量里
在项目根目录下面的 .env.local 文件添加这密钥:
# Cloudflare Turnstile 配置
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADOpKhlVxxxxxxxxx
TURNSTILE_SECRET_KEY=0x4AAAAAADOpKjQzrS4UQG9Jxxxxxxxxxxx
SITE_KEY要加"NEXT_PUBLIC"前缀

不加 NEXT_PUBLIC_ 前缀,前端代码就读不到这个变量,Turnstile 组件会拿不到 Site Key,验证功能直接无法工作。
这是 Next.js 的一个安全设计机制。Next.js 把环境变量分成两类:
不带 NEXT_PUBLIC_ 前缀的变量,只存在于服务器端(后端)。当你的前端页面在用户浏览器里运行时,这些变量根本不会被打包进去,前端代码访问它们只会得到 undefined(空值)。
带 NEXT_PUBLIC_ 前缀的变量,Next.js 会在构建时把它的值直接放进前端代码里,这样浏览器端也能访问到。
调试的时候也可以用这个官方测试的 key 先跑通流程
官方测试 Key: - sitekey: 1x00000000000000000000AA - secret: 1x0000000000000000000000000000000AA安装前端组件
在终端运行
npm install @marsidev/react-turnstile这是一个非常简单的第三方依赖包。
@marsidev/react-turnstile 本质上就是有人把 Cloudflare 官方的 Turnstile 脚本封装成了一个 React 组件,让你不用自己手动写那些底层的 JS 初始化代码。
运行 npm install @marsidev/react-turnstile 之后,你只需要做两件事:在文件顶部 import 它,然后把组件放到表单里,传入 Site Key 和一个回调函数就完了。代码量非常少。

- 在注册页面添加人机验证组件

我们要做的,是在注册表单里加一个"验证组件"。当用户填完信息准备注册时,这个组件会悄悄在后台判断他是不是真人,判断通过后会给你一张"通行证"(token),你再把这张通行证连同注册信息一起发给后端,后端拿着这张通行证去 Cloudflare 核验,核验通过才允许注册。
整个前端改动分三步。
第一步:导入组件库(Import)
import { Turnstile } from '@marsidev/react-turnstile'
意思是:告诉你的代码文件"我要用一个叫 Turnstile 的组件,它来自 @marsidev/react-turnstile 这个安装好的包"。不写这行,后面用 <Turnstile /> 时代码会报错,因为它不知道这个东西是什么。
这行写在文件最顶部,和其他 import 语句放在一起。
第二步:声明状态变量(State Declaration)
const [turnstileToken, setTurnstileToken] = useState('');
在 React 里,页面上任何会变化的数据,都需要用 useState 来存储。这里我们需要存的是 Turnstile 验证通过后返回的 token(通行证)。
为什么要这一步? 因为第三步的组件里用到了 setTurnstileToken,如果没有提前声明,代码会报错"找不到这个函数"。
第三步:放置验证组件(Component Placement)
<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={(token) => { setTurnstileToken(token) }}/>
这段代码放在表单里、注册按钮的上方。它做了两件事:
siteKey:告诉 Turnstile 组件你的站点密钥是什么,它用这个去联系 Cloudflare 做验证。这里用的是环境变量,而不是直接写死密钥,是为了安全。onSuccess就像你在便利店门口装了一个人脸识别闸机。
闸机平时是关着的。当它扫描完你的脸、确认你是真人之后,会做两件事:第一,把门打开让你进去;第二,打印一张小票给你,上面写着"此人已验证,是真人"。
(token) => { setTurnstileToken(token)}这个代码就是在说:"等闸机验证成功、把小票打出来之后,我把这张小票收好(存进 turnstileToken 这个变量里)。"
验证没通过之前,这段代码什么都不做,小票也不会有。只有验证成功的那一刻,它才会触发,把 token 这张"通行证小票"保存起来,等用户点注册按钮的时候,再把它交给后端查验。
为什么放在注册按钮上方? 纯粹是用户体验的考虑——用户填完信息,看到验证组件,验证通过后再点注册按钮,流程自然顺畅。
什么叫回调函数?
"回调"的意思就是:等某件事发生了,再来执行这段代码。
用生活比喻:你叫了外卖,然后告诉自己"等外卖到了,我就去开门"。这个"等外卖到了再执行的动作",就是回调。
onSuccess={(token) => { setTurnstileToken(token)}}意思是:"等 Turnstile 验证成功了,再执行 setTurnstileToken(token) 这行代码"。验证没成功之前,这段代码不会运行。
- 后端添加验证逻辑
前端是可以绕过的,所以必须要在后端的注册页面的逻辑下面加上人机验证,才算真正完成了整个步骤。

在后端注册逻辑的代码文件里,加上这段代码
// 从请求体中拿到前端传来的 Turnstile tokenconst { turnstileToken, ...registrationData } = await request.json()// 去 Cloudflare 验证这个 token 是不是真的const verifyResponse = awaitfetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {method:'POST',headers: { 'Content-Type':'application/json' },body:JSON.stringify({secret: process.env.TURNSTILE_SECRET_KEY,response: turnstileToken, }), })const verifyResult = await verifyResponse.json()// 如果验证失败,直接拒绝if (!verifyResult.success) {returnResponse.json( { error:'人机验证失败,请重试' }, { status:403 } )}// 验证通过,继续正常的注册流程...如果不会改,就把这段代码丢给AI,让AI来帮你改,这是AI改完之后的代码:
import { NextRequest, NextResponse } from'next/server';import bcrypt from'bcryptjs';import { getSupabaseClient } from'@/storage/database/supabase-client';exportasyncfunctionPOST(request: NextRequest) {try {const { username, password, turnstileToken } = await request.json(); // 👈 这里加了 turnstileToken// Turnstile 人机验证 👈 这一整块是新加的const verifyResponse = awaitfetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {method:'POST',headers: { 'Content-Type':'application/json' },body:JSON.stringify({secret: process.env.TURNSTILE_SECRET_KEY,response: turnstileToken, }), } );const verifyResult = await verifyResponse.json();if (!verifyResult.success) {returnNextResponse.json({ error:'人机验证失败,请重试' }, { status:403 }); }// 人机验证结束 👆if (!username || !password) {returnNextResponse.json({ error:'用户名和密码不能为空' }, { status:400 }); }if (username.length < 2 || username.length > 50) {returnNextResponse.json({ error:'用户名长度需在2-50之间' }, { status:400 }); }if (password.length < 6) {returnNextResponse.json({ error:'密码长度不能少于6位' }, { status:400 }); }const supabase = getSupabaseClient();const { data: existing } = await supabase .from('users') .select('id') .eq('username', username) .maybeSingle();if (existing) {returnNextResponse.json({ error:'用户名已存在' }, { status:409 }); }const hashedPassword = await bcrypt.hash(password, 10);const { data, error } = await supabase .from('users') .insert({ username, password: hashedPassword }) .select('id, username, created_at') .single();if (error) {console.error('Register DB error:', error);returnNextResponse.json({ error:'注册失败,请重试' }, { status:500 }); }returnNextResponse.json({user: { id: data.id, username: data.username }, }); } catch (err) {console.error('Register API error:', err);returnNextResponse.json({ error:'注册失败,请重试' }, { status:500 }); }}总结一下改了哪两个地方:
第一处,第一行读取请求数据的地方,原来只读 username 和 password,现在多读一个 turnstileToken:
// 改前const { username, password } = await request.json();// 改后const { username, password, turnstileToken } = await request.json();第二处,紧接着在这行下面,插入整块 Turnstile 验证逻辑。它必须放在最前面,这样机器人的请求在进入任何注册流程之前就被拦截掉了。
注意事项:
第一点:验证地址是固定的
https://challenges.cloudflare.com/turnstile/v0/siteverify 这个地址是 Cloudflare 官方提供的"核验窗口",全世界所有用 Turnstile 的人都用同一个地址。你不需要理解它,照抄就行,一个字母都不要改。
第二点:每张通行证只能用一次
token 就像电影票,撕过一次就作废了。用户注册成功后,这张 token 就失效了,下次注册必须重新验证、拿一张新的。这是 Cloudflare 故意设计的,防止有人拿一张通行证反复用。
第三点:通行证有5分钟有效期
用户打开注册页面,Turnstile 验证通过,发了一张 token 给你。但如果用户发呆了10分钟才点注册按钮,这张 token 已经过期了,后端验证会失败。遇到这种情况,让用户刷新页面重新验证一次就好了,这是正常现象。
设置好之后,先让 AI 验证一下看是否有 Bug 需要修复

- 最后验证
配置好人机验证之后,正常的用户会看到注册页面出现一个小方块的人机验证按钮。
如果是机器人批量注册,通过脚本调用注册接口,由于它拿不到 Cloudflare Turnstile token,后端验证就会失败,注册会被拒绝。即使它伪造了 token,Cloudflare 也会验证失败,注册同样会被拒绝。
这样我们的 API 就不会被薅羊毛了。只要网站有注册或免费的资源,比如调用API 就一定要加人机验证。
测试成功:

感谢你看到这里,下期分享怎么给网站添加云存储的功能。
[AI编程] 系列文章:
夜雨聆风
