@dxv-systems/turnstile

中文文档
0.3.2发布于 2026/11/10MITTypeScriptCommonJS零依赖近 30 天下载 9807

Cloudflare Turnstile — siteverify core, Express and Next.js route guards, and the React widget

原始使用文档
文档目录11 节
  1. Entry points
  2. Server
  3. What denies
  4. The client address
  5. Deciding failClosed
  6. Core API
  7. Client
  8. Theme
  9. Test site key
  10. Wrap it in your app
  11. Module format

@dxv-systems/turnstile

Cloudflare Turnstile for server and client: the /siteverify core, route guards for Express and Next.js App Router, and a React widget.

npm i @dxv-systems/turnstile

Entry points

ImportContents
@dxv-systems/turnstileCore: verifyTurnstileToken, evaluateTurnstile, clientIp, turnstileHeaders, TURNSTILE_HEADER, TURNSTILE_TEST_SITE_KEY
@dxv-systems/turnstile/expressrequireTurnstile middleware
@dxv-systems/turnstile/nextassertTurnstile for fetch-style route handlers
@dxv-systems/turnstile/reactTurnstile widget component, plus turnstileHeaders, TURNSTILE_HEADER, TURNSTILE_TEST_SITE_KEY

The client sends the token in the X-Turnstile-Token request header (TURNSTILE_HEADER).

Server

Both guards take the same two options and share one policy implementation, so they behave identically.

OptionMeaning
secretYour Turnstile secret key, for example process.env.TURNSTILE_SECRET_KEY. undefined is a valid state outside production.
failClosedWhat an absent secret means. true: deny with 503 rather than serve the route unguarded. false: no secret, no guard.

Express

import { requireTurnstile } from "@dxv-systems/turnstile/express";

app.post("/api/login", requireTurnstile({
  secret: process.env.TURNSTILE_SECRET_KEY,
  failClosed: isProduction(),
}), handler);

A denied request gets a JSON body { message } with the status from the table below.

Next.js App Router

import { assertTurnstile } from "@dxv-systems/turnstile/next";

export async function POST(req: NextRequest) {
  const denied = await assertTurnstile(req, {
    secret: process.env.TURNSTILE_SECRET_KEY,
    failClosed: isProduction(),
  });
  if (denied) return denied;

  const body = Body.parse(await req.json());
  // ...
}

assertTurnstile takes a plain web Request and returns a plain Response (or null when the request may pass). It does not import Next, so it works in any fetch-style handler. It never reads the body, so your handler can still parse it.

What denies

Every case fails closed, with one deliberate exception.

SituationResult
No secret, failClosed: falseallowed: the explicit "no guard here" state
No secret, failClosed: true503, and an error log
No token on the request403, without calling /siteverify
Challenge rejected403, error codes logged
/siteverify unreachable, times out, or returns 5xx503: an outage must not become a bypass
Secret wrong (invalid-input-secret and similar)503, not 403: this is a server misconfiguration, not a bot

failClosed governs an absent secret only. Once a secret is set, a missing token and an unavailable check both deny.

The client address

remoteip is read from the x-real-ip request header only, never x-forwarded-for. A caller can send its own x-forwarded-for, and proxies append to it rather than replace it, so its leftmost entry is caller-controlled. Make sure your reverse proxy or host sets x-real-ip to the client address.

There is no fallback. Cloudflare scores the token against remoteip, so a wrong value is worse than none. When the header is absent, no remoteip is sent.

Deciding failClosed

This package never reads the environment. How an app recognises its own production deployment differs per host, so you pass failClosed yourself. Typical choice: true in every environment that has a real widget and secret (production, staging), false in local development and throwaway preview environments.

Be careful with NODE_ENV: many setups set it to "production" for every build, including previews, so it is often not a reliable signal on its own.

Core API

Use the core directly if you need another framework:

  • verifyTurnstileToken(token, secret, remoteIp?) calls /siteverify and resolves to { ok: true }, { ok: false, reason: "rejected", codes } or { ok: false, reason: "unavailable", detail }. It never throws.
  • evaluateTurnstile({ secret, failClosed, token, remoteIp?, path? }) applies the policy above and resolves to { allow: true } or { allow: false, status: 403 | 503, message }. path is used in log lines only.
  • clientIp(get) reads x-real-ip through a header getter (name) => string | null | undefined.

Client

import { Turnstile, turnstileHeaders, type TurnstileHandle } from "@dxv-systems/turnstile/react";

const [token, setToken] = useState<string | null>(null);
const widget = useRef<TurnstileHandle>(null);

async function onSubmit() {
  try {
    await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json", ...turnstileHeaders(token) },
      body: JSON.stringify({ email, password }),
    });
  } finally {
    widget.current?.reset(); // tokens are single-use
  }
}

<Turnstile ref={widget} siteKey={SITE_KEY} failedMessage="Verification could not load." onToken={setToken} />
<button type="submit" disabled={!token}>Sign in</button>

Props: siteKey, onToken, failedMessage, and optional className and theme. siteKey and failedMessage are props, not read from the environment: the public-variable prefix differs per bundler (VITE_*, NEXT_PUBLIC_*), and not every app has an i18n runtime.

Theme

theme defaults to "light", not Cloudflare's own "auto". "auto" follows the operating system's prefers-color-scheme, not your app's theme. A light-only app would then show a dark widget to every visitor whose OS is in dark mode.

Pass "auto" if your app follows the system scheme, or "dark" if it is dark-only. An app with its own theme toggle should pass its current theme, so the widget re-renders when it changes.

Test site key

Where there is no real widget, for example in local development or on preview hostnames that cannot match the widget's domain allowlist, fall back to TURNSTILE_TEST_SITE_KEY, Cloudflare's always-passes key. This does not weaken production: the site key is public, the server is the gate, and a token minted with the test key fails /siteverify against a real secret.

Wrap it in your app

Consume ./react through a thin app-owned component rather than importing it into pages directly:

// components/turnstile.tsx
"use client"; // Next only

import { Turnstile as Base, TURNSTILE_TEST_SITE_KEY, type TurnstileHandle } from "@dxv-systems/turnstile/react";

export const Turnstile = forwardRef<TurnstileHandle, { onToken: (t: string | null) => void }>(
  function Turnstile(props, ref) {
    return (
      <Base
        ref={ref}
        siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || TURNSTILE_TEST_SITE_KEY}
        failedMessage={t("turnstile.failed")}
        {...props}
      />
    );
  },
);

The wrapper supplies the site key and the copy. In Next it is also the "use client" boundary. The package's own "use client" directive survives the build but lands after the "use strict" that the CommonJS output adds, so do not rely on it to set the boundary for you.

Module format

The package ships CommonJS, so it loads in runtimes that cannot require ESM-only dependencies. The ./react entry does not import the server code, so /siteverify logic stays out of browser bundles.

版本与发布记录

@dxv-systems/turnstile 共发布 6 个版本,首次发布于 2026/08/19;过去 12 个月发布了 6 个版本。

版本发布日期距今
0.3.2latest2026/11/103 天前
0.3.12026/08/202 个月前
0.3.02026/08/202 个月前
0.2.02026/08/202 个月前
0.1.12026/08/192 个月前
0.1.02026/08/192 个月前

关于 @dxv-systems/turnstile 的常见问题

@dxv-systems/turnstile 是什么?

Cloudflare Turnstile — siteverify core, Express and Next.js route guards, and the React widget

如何安装 @dxv-systems/turnstile?

在项目目录运行 npm install @dxv-systems/turnstile,或使用 pnpm add / yarn add / bun add。

@dxv-systems/turnstile 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dxv-systems/turnstile 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@dxv-systems/turnstile 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@dxv-systems/turnstile 可以商用吗?

@dxv-systems/turnstile 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dxv-systems/turnstile 的下载量有多少?

根据 npm 官方下载统计,@dxv-systems/turnstile 在 2026/10/13 – 2026/11/11 这 30 天内被下载 9,807 次,日均约 327 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dxv-systems/turnstile 还在维护吗?

最新版本 0.3.2 发布于 2026/11/10(3 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/08/19。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。