You need: turnstile composed, which brings secrets with it.
pithy add turnstile --with-prerequisitesThe routes worth gating
The turnstile capability is what does the gating, pithy turnstile mints the keys, and routing and verification is how the gate composes with the others on a route.
Anything public that costs you something when a bot finds it:
| Route | What it costs |
|---|---|
| Magic-link send | Mail, to any address a bot names |
| One-time-code send | The same |
| Signup | Rows, and a user table full of nothing |
| A contact or lead form | Your inbox |
| Invite acceptance | Depends on what an invite grants |
The first two are the ones that matter most, because a send route is a machine that will mail anybody on request.
Auth’s two send routes are gated for free
Compose turnstile beside auth and the magic-link and one-time-code send routes are gated automatically, with no wiring at all.
No route line to add, no config, nothing to remember. That is the whole integration, and it covers the two routes a bot most wants.
Gating one of your own
One import, one line:
import { turnstile } from "@pithy-sh/turnstile/src/http/middleware";
app.use("/signup", turnstile());It stacks on top of whatever that route already declares. A public signup route that still requires a token is exactly the shape this exists for — because is this a human is a different question from who is this, and a humanity check can never be a route’s identity gate.
Binding a token to its route
app.use("/auth/magic-link", turnstile({ action: "login" }));With an action set, the middleware asserts the returned action matches and denies on mismatch.
That binds a token to the route it was solved for — so a token harvested from your signup form cannot be replayed against your login. Use it wherever the route has a meaningful name, which is most of the time.
If your handler reads the raw body
By default the gate reads the token from the request body, and the framework caches the body so your handler can read it again normally.
If a downstream handler reads the raw stream instead, use header mode so the gate never touches the body:
app.use("/webhook-ish", turnstile({ header: "x-turnstile-token" }));Rendering the widget
The package ships no front-end component. Your app renders the widget with the public sitekey and posts the response token; the middleware verifies it.
The scaffolded React front end includes a widget that does exactly that, reading the sitekey from the composed config at runtime — which is why there are no provider flags anywhere: enabling a widget stays a config edit rather than a regeneration.
It fails closed
A missing token, a failed verdict, an action mismatch, an unreachable verification service, a malformed response — all deny.
A bot gate that opens when it cannot check is not a bot gate.
The error that sends people to the wrong place
Three codes, and the third is the one to recognize:
| Code | Status | Means |
|---|---|---|
turnstile/missing_token | 400 | No token where one was required |
turnstile/failed | 403 | The token did not pass, or the check could not complete |
turnstile/config | 500 | The deployment is at fault |
A wrong secret is not a failed challenge. Cloudflare answers with a specific error for a secret it never issued, and every request is refused for as long as it is wired.
That is a 500 rather than a 403 on purpose: a 403 would send whoever is debugging it to look at the user, which is the wrong person and an hour gone. The 500’s action line names the command that fixes it.
The same code covers a test key used outside dev and staging — a secret that passes everybody must not be able to stand in for a widget.
Dev and staging use test keys
pithy turnstile provision wires Cloudflare’s documented test keys into dev and staging, and creates a real widget bound to your production hostname.
The gate enforces that boundary rather than trusting it: Cloudflare flags its own answers from a test key, so a Worker stamped for production refuses one.
Repairing a production gate that answers turnstile/config
Cloudflare never returns an existing widget’s secret, so a re-provision cannot recompose it — it leaves the stored secret as it was and says so.
pithy turnstile deprovision
pithy turnstile provisionThat is the repair, and it is the one case where the two-step is necessary rather than tidy.
Check it worked
- A request with no token gets
turnstile/missing_token - A request with a valid test token passes, locally
- With an action set, a token solved elsewhere is refused
- With auth composed, the send routes are gated without you adding a line