适用场景
Cloudflare Turnstile 用于替代传统图片验证码,常见接入位置包括登录、注册、发送邮箱验证码、提交表单等容易被机器人刷接口的入口。
- 前端展示 Turnstile 组件,拿到一次性的
token。 - 后端使用
Secret key调用 Cloudflaresiteverify接口验证 token。 - 校验通过后再继续执行登录、发送邮件或其他受保护业务。
创建 Turnstile Widget
进入 Turnstile 页面
在 Cloudflare 控制台左侧菜单中打开 Application security,再进入 Turnstile。

新建 Widget
进入 Turnstile widgets 页面后,点击 Add widget manually。

填写 widget 名称,例如 login,然后在 Hostname Management 中添加允许使用该 widget 的域名。

开发环境可以临时添加 localhost。生产环境建议添加真实业务域名,例如 <YOUR_DOMAIN>。

确认 hostname 已加入后点击 Save。

选择验证模式
Widget Mode 推荐先使用 Managed,由 Cloudflare 根据访问风险自动决定是否需要用户交互。

创建完成后复制 Site key 和 Secret key。

Site key可以放到前端配置中;Secret key只能保存在后端或环境变量里,不能提交到前端仓库。
前端接入
环境变量
前端只配置 Site key。
VITE_TURNSTILE_SITE_KEY=<TURNSTILE_SITE_KEY>
本地开发和自动化测试可以使用 Cloudflare 官方测试 Site key,不用把真实 Site key 配到测试环境。
| 测试 Site key | 结果 | Widget 类型 |
|---|---|---|
1x00000000000000000000AA |
永远通过 | Visible |
2x00000000000000000000AB |
永远失败 | Visible |
1x00000000000000000000BB |
永远通过 | Invisible |
2x00000000000000000000BB |
永远失败 | Invisible |
3x00000000000000000000FF |
强制交互挑战 | Visible |
Vue 组件示例
下面组件会动态加载 Cloudflare Turnstile 脚本,渲染成功后通过 v-model 回传 token。
<template>
<div class="turnstile-widget">
<div v-if="missingSiteKey" class="turnstile-widget__message">
{{ unavailableText }}
</div>
<div v-else ref="containerRef" class="turnstile-widget__container"></div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
defineOptions({ name: 'TurnstileWidget' })
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script'
const TURNSTILE_SCRIPT_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
interface TurnstileRenderConfig {
sitekey: string
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
action?: string
language?: string
callback?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: (errorCode?: string) => void
}
interface TurnstileClient {
render(container: string | HTMLElement, options: TurnstileRenderConfig): string
reset(widgetId?: string): void
remove(widgetId: string): void
}
declare global {
interface Window {
turnstile?: TurnstileClient
}
}
let turnstileLoadPromise: Promise<TurnstileClient> | null = null
interface Props {
modelValue?: string
siteKey?: string
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
action?: string
language?: string
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
siteKey: '',
theme: 'auto',
size: 'normal',
action: 'send_magic_link',
language: 'auto',
unavailableText: 'Cloudflare Turnstile site key is not configured'
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'verified', value: string): void
(event: 'expired'): void
(event: 'error', value?: string): void
(event: 'loaded'): void
}>()
const containerRef = ref<HTMLElement>()
const widgetId = ref('')
const currentSiteKey = computed(
() => props.siteKey || import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
)
const missingSiteKey = computed(() => !currentSiteKey.value)
function loadTurnstile(): Promise<TurnstileClient> {
if (window.turnstile) return Promise.resolve(window.turnstile)
if (turnstileLoadPromise) return turnstileLoadPromise
turnstileLoadPromise = new Promise((resolve, reject) => {
const existingScript = document.getElementById(
TURNSTILE_SCRIPT_ID
) as HTMLScriptElement | null
const script = existingScript || document.createElement('script')
const handleLoad = () => {
if (window.turnstile) {
resolve(window.turnstile)
return
}
turnstileLoadPromise = null
reject(new Error('Cloudflare Turnstile API is unavailable'))
}
const handleError = () => {
turnstileLoadPromise = null
reject(new Error('Failed to load Cloudflare Turnstile script'))
}
script.addEventListener('load', handleLoad, { once: true })
script.addEventListener('error', handleError, { once: true })
if (!existingScript) {
script.id = TURNSTILE_SCRIPT_ID
script.src = TURNSTILE_SCRIPT_SRC
script.async = true
script.defer = true
document.head.appendChild(script)
}
})
return turnstileLoadPromise
}
function clearToken() {
emit('update:modelValue', '')
}
function removeWidget() {
if (!widgetId.value || !window.turnstile) return
try {
window.turnstile.remove(widgetId.value)
} catch (error) {
console.warn('Failed to remove Cloudflare Turnstile widget:', error)
} finally {
widgetId.value = ''
}
}
async function renderWidget() {
if (missingSiteKey.value || !containerRef.value) return
try {
const turnstile = await loadTurnstile()
await nextTick()
if (!containerRef.value) return
removeWidget()
widgetId.value = turnstile.render(containerRef.value, {
sitekey: currentSiteKey.value,
theme: props.theme,
size: props.size,
action: props.action,
language: props.language,
callback: (token: string) => {
emit('update:modelValue', token)
emit('verified', token)
},
'expired-callback': () => {
clearToken()
emit('expired')
},
'error-callback': (errorCode?: string) => {
clearToken()
emit('error', errorCode)
}
})
emit('loaded')
} catch (error: any) {
clearToken()
emit('error', error?.message || 'Failed to load Cloudflare Turnstile')
}
}
function reset() {
clearToken()
if (!widgetId.value || !window.turnstile) return
window.turnstile.reset(widgetId.value)
}
onMounted(renderWidget)
onBeforeUnmount(() => {
removeWidget()
clearToken()
})
watch(
() => [currentSiteKey.value, props.theme, props.size, props.action, props.language],
() => {
clearToken()
renderWidget()
}
)
defineExpose({ reset })
</script>
后端校验
配置项
后端使用 Secret key 调用 Cloudflare 校验接口。生产环境必须通过环境变量注入真实密钥。
turnstile:
enabled: ${TURNSTILE_ENABLED:true}
secret-key: ${TURNSTILE_SECRET_KEY:<TURNSTILE_SECRET_KEY>}
verify-url: ${TURNSTILE_VERIFY_URL:https://challenges.cloudflare.com/turnstile/v0/siteverify}
connect-timeout-millis: ${TURNSTILE_CONNECT_TIMEOUT_MILLIS:3000}
read-timeout-millis: ${TURNSTILE_READ_TIMEOUT_MILLIS:5000}
本地测试可以使用 Cloudflare 官方测试密钥,避免把真实 Secret 写进配置文件。
| 测试 Secret Key | 结果 |
|---|---|
1x0000000000000000000000000000000AA |
永远通过 |
2x0000000000000000000000000000000AA |
永远失败 |
3x0000000000000000000000000000000AA |
模拟 token 已被使用 |
推荐本地测试组合如下。
| 场景 | 前端测试 Site key | 后端测试 Secret key | 预期结果 |
|---|---|---|---|
| 成功流程 | 1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
success=true |
| 失败流程 | 2x00000000000000000000AB |
2x0000000000000000000000000000000AA |
success=false |
| 重复 token | 1x00000000000000000000AA |
3x0000000000000000000000000000000AA |
timeout-or-duplicate |
测试
Site key会生成 dummy token;生产Secret key会拒绝 dummy token。因此本地测试时前端Site key和后端Secret key要同时切换为测试密钥。
Spring Boot 配置类
package com.draft.auth.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "turnstile")
public class TurnstileProperties
{
private boolean enabled = true;
private String secretKey;
private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
private int connectTimeoutMillis = 3000;
private int readTimeoutMillis = 5000;
}
校验服务
package com.draft.auth.service;
import com.draft.auth.config.TurnstileProperties;
import com.draft.auth.domain.TurnstileVerifyResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@Slf4j
@Service
public class TurnstileService
{
private final TurnstileProperties turnstileProperties;
private final RestTemplate restTemplate;
public TurnstileService(TurnstileProperties turnstileProperties)
{
this.turnstileProperties = turnstileProperties;
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(turnstileProperties.getConnectTimeoutMillis());
requestFactory.setReadTimeout(turnstileProperties.getReadTimeoutMillis());
this.restTemplate = new RestTemplate(requestFactory);
}
public String verifyToken(String token, String remoteIp)
{
if (!turnstileProperties.isEnabled()) {
return null;
}
if (!StringUtils.hasText(token)) {
return "人机验证不能为空";
}
if (!StringUtils.hasText(turnstileProperties.getSecretKey())) {
log.error("Turnstile 校验已开启,但未配置 secret-key");
return "人机验证服务未配置";
}
HttpEntity<MultiValueMap<String, String>> requestEntity =
buildVerifyRequest(token, remoteIp);
try {
ResponseEntity<TurnstileVerifyResponse> responseEntity = restTemplate.postForEntity(
turnstileProperties.getVerifyUrl(),
requestEntity,
TurnstileVerifyResponse.class
);
return buildVerifyError(responseEntity.getBody());
} catch (RestClientException ex) {
log.warn("Turnstile 远端校验失败: {}", ex.getMessage());
return "人机验证服务暂不可用";
}
}
private HttpEntity<MultiValueMap<String, String>> buildVerifyRequest(
String token,
String remoteIp
) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("secret", turnstileProperties.getSecretKey());
form.add("response", token);
if (StringUtils.hasText(remoteIp)) {
form.add("remoteip", remoteIp);
}
return new HttpEntity<>(form, headers);
}
private String buildVerifyError(TurnstileVerifyResponse response)
{
if (response == null) {
return "人机验证失败";
}
if (Boolean.TRUE.equals(response.getSuccess())) {
return null;
}
log.warn("Turnstile 校验未通过,错误码: {}", response.getErrorCodes());
return "人机验证失败";
}
}
响应对象
package com.draft.auth.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class TurnstileVerifyResponse
{
private Boolean success;
@JsonProperty("error-codes")
private List<String> errorCodes;
}
接入流程
- 前端页面渲染
TurnstileWidget。 - 用户通过 Turnstile 后,组件拿到
token。 - 前端把
token随登录、注册或发送验证码请求一起提交给后端。 - 后端调用
https://challenges.cloudflare.com/turnstile/v0/siteverify。 success=true才继续执行业务;失败时返回统一提示。
常见问题
Secret key 能不能放前端
不能。Secret key 等价于后端校验凭据,只能放在后端环境变量或配置中心中。
本地 localhost 怎么测试
如果使用真实 Site key,开发环境可以在 Turnstile widget 的 hostname 中添加 localhost。如果使用 Cloudflare 官方测试 Site key / Secret key,则可以直接用于 localhost、127.0.0.1 或其他开发域名,不需要先在控制台添加 hostname。
token 是否可以重复使用
不能。Turnstile token 是一次性校验结果,应当在业务请求中立即提交后端验证,后端验证后不应复用。
