@dxv-systems/turnstile
v0.3.1
Published
Cloudflare Turnstile for DXV apps — siteverify core, Express and Next.js route guards, and the React widget
Downloads
1,764
Maintainers
Readme
@dxv-systems/turnstile
Cloudflare Turnstile for DXV apps: the /siteverify core, route guards for Express and Next.js App Router, and the React widget.
Extracted from dxv-platform, where it guards the portal's unauthenticated auth routes.
npm i @dxv-systems/turnstileServer
Both guards take the same two options and share one policy implementation, so they cannot drift apart.
| Option | Meaning |
|---|---|
| secret | Usually process.env.TURNSTILE_SECRET_KEY. undefined is a legitimate state outside production. |
| failClosed | What an absent secret means here. true → deny with 503 rather than serve 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: isGuardedDeployment(),
}), handler);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: isGuardedDeployment(),
});
if (denied) return denied;
const body = Body.parse(await req.json());
// ...
}assertTurnstile takes a plain web Request and returns a plain Response — nothing here imports Next, so it works in any fetch-style handler. It never reads the body, so the handler's own parse still works.
What denies
Every axis 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, codes logged |
| /siteverify unreachable, times out, or 5xxs | 503 — an outage must not become a bypass |
| Secret wrong (invalid-input-secret and friends) | 503, not 403 — this is our deploy being wrong, not the visitor being a bot |
failClosed governs an absent secret only. Once a secret exists, a missing token and an unavailable check both deny regardless.
The client address
remoteip is taken from x-real-ip only — the header Vercel's own ipAddress() helper reads. Never x-forwarded-for: a caller can send its own, and proxies append rather than replace, so the leftmost entry is caller-controlled.
There is deliberately no fallback. Cloudflare scores the token against remoteip, so a forged or wrong value poisons the scoring — worse than sending none, which is what happens when the header is absent.
Why no environment sniffing
This package never reads the environment. How an app recognises its own production deployment differs per host and per repo, and baking one answer in here would export it to every consumer. Callers pass failClosed and keep that decision where it belongs.
A worked example of isGuardedDeployment, and the two traps behind it:
// NOT NODE_ENV: vercel.json commonly pins it to "production" for every target,
// preview included. NOT VERCEL_ENV either: a Vercel *custom* environment
// reports "preview", so staging is invisible to it — and staging usually does
// have a real widget and secret, so it must fail closed too.
export function isGuardedDeployment(): boolean {
const env = process.env.VERCEL_TARGET_ENV;
return env === "production" || env === "staging";
}VERCEL_TARGET_ENV names custom environments and requires expose_system_env_vars: true on the project. Where it is unset — local dev, or a host that is not Vercel — the guard falls open, which is the right outcome for an environment with no widget provisioned.
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>siteKey and failedMessage are props, not read from the environment: the public-var prefix differs per bundler (VITE_*, NEXT_PUBLIC_*), and consuming apps disagree about whether they have an i18n runtime.
Theme
theme defaults to "light", deliberately not Cloudflare's own "auto". auto follows the operating system's prefers-color-scheme, which has nothing to do with the host app's theme — so a light-only app renders a black widget for every visitor whose OS is in dark mode, which is what happened across the DXV fleet before 0.2.0.
Pass "auto" explicitly if your app genuinely 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.
Where there is no real widget — local dev, preview deployments whose randomised hostnames can never match the domain allowlist — fall back to TURNSTILE_TEST_SITE_KEY, Cloudflare's always-passes key. That cannot weaken production: the sitekey is public, the server is the gate, and a token minted against it 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 is where the sitekey and the copy come from, and 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 emit adds, so do not rely on it to make the boundary for you.
Development
npm run build --workspace @dxv-systems/turnstile
npm run test --workspace @dxv-systems/turnstilesrc/shared.ts holds the pieces both halves need (TURNSTILE_HEADER, TURNSTILE_TEST_SITE_KEY, turnstileHeaders). Keep it that way: if ./react imports them from ./index instead, the whole server policy — /siteverify, the error-code table, evaluateTurnstile — lands in consumers' browser bundles, because CommonJS output defeats tree-shaking.
Output is CommonJS — Vercel's serverless runtime bundles to CJS, and an ESM-only dependency there is a runtime ERR_REQUIRE_ESM, not a build error.
