Using Turnstile

Stacking the gate

Bot-gating a public route is the walkthrough. The sitekey comes from provisioning, the widget from the React components, and every option is in the reference.

app.post("/signup", turnstile({ action: "signup" }), handler)
Option
modeWhich widget — visible or invisible. Required only when you run both
fieldThe body field carrying the token. Defaults to cf-turnstile-response
headerRead the token from a header instead of the body
actionThe action label the token must have been solved for

The action is what stops cross-route reuse

Turnstile bakes an action into the token at widget render and echoes it back from siteverify. When you set action, the gate asserts the returned action matches and denies on a mismatch.

Without it, one solved challenge on your login page is a token that works against your signup form, your contact form, and anything else sharing the widget.

Sign-in is gated for you

When Turnstile is composed alongside auth, the magic-link and OTP send routes wear the humanity check automatically, solved for the kit’s own login action. You wire nothing.

Two modes, one of each per domain

visible is a Cloudflare managed widget — Cloudflare decides whether an interaction is shown. For a surface where the challenge should be seen, like a login page.

invisible runs silently. For a form that should not interrupt, like a lead capture.

The logical maximum is one of each per domain, which is why mode is inferable when you run only one.

The sitekey is public, and lives in config

The front end renders the widget with it, so it is not a secret and is not stored as one. It reaches the browser through the client projection, indexed by your environment names verbatim — a bundle built for prod reads the prod sitekey. These are not free-form labels.

The secret key feeds siteverify and lives in the secret store.

The gate fails closed, in every direction

A transport error, a non-OK status, a non-JSON body, or a response shape the schema does not recognize — all of them deny.

Validating the response body is itself part of the security boundary. An unexpected shape is treated as a failure, never as a pass. A bot gate that opens when something unexpected happens is a bot gate that opens exactly when it is being attacked.

Three refusals, and one of them is not about the user

CodeStatusMeans
turnstile/missing_tokenNo token where one was expected
turnstile/failed403The token did not pass
turnstile/config500Cloudflare does not recognize your secret

That last row is the one worth having. Cloudflare answers HTTP 400 for a secret it does not recognize, and rendering that as turnstile/failed would tell an operator that a user failed a challenge when the truth is that nobody can ever pass one here — and every request is refused, so the search never converges.

turnstile/config names the command that fixes it in its action line.

Both directions still deny.

ESC