Bot-gate a public route

You need: turnstile composed, which brings secrets with it.

pithy add turnstile --with-prerequisites

The 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:

RouteWhat it costs
Magic-link sendMail, to any address a bot names
One-time-code sendThe same
SignupRows, and a user table full of nothing
A contact or lead formYour inbox
Invite acceptanceDepends 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:

CodeStatusMeans
turnstile/missing_token400No token where one was required
turnstile/failed403The token did not pass, or the check could not complete
turnstile/config500The 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 provision

That 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
ESC