Skip to content

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.

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 })],
}))
env.ts
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.

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.

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 message too, which Better Auth’s client puts on error, 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.

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-auth types only, nothing from void or the rest of @recranet/flare.