turnstile
Cloudflare Turnstile on the
Better Auth endpoints a script can hammer without a session: sign-in (email
and magic link), sign-up and the password-reset request. Skipped when
TURNSTILE_SECRET_KEY is unset. examples/crm uses it.
Set up
Section titled “Set up”A Better Auth plugin, in auth.ts. The secret is read per request:
import { defineAuth } from "void/auth"import { env } from "void/env"import { turnstile } from "@recranet/flare/turnstile"
export default defineAuth(({ defaults }) => ({ ...defaults, plugins: [turnstile({ secretKey: () => env.TURNSTILE_SECRET_KEY })],}))TURNSTILE_SITE_KEY: string().optional(),TURNSTILE_SECRET_KEY: string().optional(),Create a widget in the Cloudflare dashboard (Turnstile), then
void secret put TURNSTILE_SECRET_KEY and set TURNSTILE_SITE_KEY.
The page renders the widget and sends its token in the x-captcha-response
header. With Better Auth’s client:
await auth.signIn.email({ email, password, fetchOptions: { headers: { "x-captcha-response": token } } })A token is good for one check, so reset the widget after a failed attempt.
The CRM’s src/components/Turnstile.vue loads Cloudflare’s script, renders
the widget when the loader passes a site key, and exposes reset(); see
pages/sign-in.vue.
Options
Section titled “Options”| Option | Default | What |
|---|---|---|
secretKey |
The secret, or a function read per request. Unset or empty skips the check | |
endpoints |
TURNSTILE_ENDPOINTS |
Better Auth paths checked on POST, relative to its basePath |
verifyUrl |
Cloudflare’s siteverify | For tests or a proxy |
timeout |
5000 |
Milliseconds to wait for siteverify before refusing |
TURNSTILE_ENDPOINTS is /sign-in/email, /sign-in/magic-link,
/sign-up/email and /request-password-reset.
Answers
Section titled “Answers”| Case | Status | Body |
|---|---|---|
| No token | 400 | { "error": "The anti-bot check did not complete. …", "code": "CAPTCHA_MISSING" } |
| Cloudflare rejects the token | 403 | { "error": "The anti-bot check did not pass. …", "code": "CAPTCHA_FAILED" } |
| Siteverify unreachable, slow, erroring or unreadable | 403 | CAPTCHA_FAILED, and a warning in the log |
| No secret | passes |
The body is access’s { error, code }, with Studio’s codes,
plus message for Better Auth’s client. The header is Better Auth’s
convention, the same one its own captcha plugin reads.
Why a Better Auth plugin, not Void middleware
Section titled “Why a Better Auth plugin, not Void middleware”- The paths are Better Auth’s, relative to its
basePath, so they follow the auth config instead of repeating/api/auth/...in the app. - It runs only for requests Better Auth handles; server calls
(
auth.api.signInEmail(...)) skip it, as they should. - A refusal carries
messagetoo, which Better Auth’s client puts onerror, so the sign-in page shows it with no extra code.
Better Auth’s own captcha plugin was the other option. It needs the
secret when the plugin is built and answers 500 without it, so it can’t be
switched off by leaving the secret unset. This one is off without a secret,
as in Studio (api/src/lib/turnstile.ts). With a secret it fails closed:
Studio lets a request through when Cloudflare can’t be reached, this one
refuses it with a 403, so a failed check never passes and never shows as a
500.
Local runs and tests
Section titled “Local runs and tests”Leave the secret unset, or use Cloudflare’s test pair, which always passes:
site key 1x00000000000000000000AA, secret
1x0000000000000000000000000000000AA. A real widget’s domain list doesn’t
include localhost, so a production key fails every check on your machine.
The CRM’s tests run the Worker with a secret and TURNSTILE_VERIFY_URL
pointing at a fake siteverify on 127.0.0.1 in the test process, so nothing
leaves the machine: a missing, a bad and a good token, and a siteverify
error.
- Pair it with rate-limit on the same endpoints: Turnstile stops scripts, the limit stops a person (or a solved token farm) guessing passwords.
- The module imports
better-authtypes only, nothing fromvoidor the rest of@recranet/flare.