@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
| Import | Contents |
|---|---|
@dxv-systems/turnstile | Core: verifyTurnstileToken, evaluateTurnstile, clientIp, turnstileHeaders, TURNSTILE_HEADER, TURNSTILE_TEST_SITE_KEY |
@dxv-systems/turnstile/express | requireTurnstile middleware |
@dxv-systems/turnstile/next | assertTurnstile for fetch-style route handlers |
@dxv-systems/turnstile/react | Turnstile 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.
| Option | Meaning |
|---|---|
secret | Your Turnstile secret key, for example process.env.TURNSTILE_SECRET_KEY. undefined is a valid state outside production. |
failClosed | What 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.
| Situation | Result |
|---|---|
No secret, failClosed: false | allowed: the explicit "no guard here" state |
No secret, failClosed: true | 503, and an error log |
| No token on the request | 403, without calling /siteverify |
| Challenge rejected | 403, error codes logged |
/siteverify unreachable, times out, or returns 5xx | 503: 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/siteverifyand 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 }.pathis used in log lines only.clientIp(get)readsx-real-ipthrough 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.