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 | |
|---|---|
mode | Which widget — visible or invisible. Required only when you run both |
field | The body field carrying the token. Defaults to cf-turnstile-response |
header | Read the token from a header instead of the body |
action | The 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
| Code | Status | Means |
|---|---|---|
turnstile/missing_token | No token where one was expected | |
turnstile/failed | 403 | The token did not pass |
turnstile/config | 500 | Cloudflare 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.