Blog

A capability with no tables, no routes, no bindings

2026-06-19

An isometric illustration of a shield with a check mark raised on its face

Bots find your signup form about four hours after it goes live. Not because anyone targeted you — because everything gets scanned, and a form that accepts an email address and sends mail is worth trying.

pithy add turnstile puts a Cloudflare Turnstile humanity check in front of the routes you choose. It is the smallest capability in the kit, and the most useful one to read if you want to understand how the pieces fit together.

What it does not have

Most capabilities bring tables, migrations, routes and bindings. Turnstile brings none of them:

  • No tables and no migrations. There is nothing to store.
  • No routes of its own.
  • No global middleware.
  • No bindings. Its widget secret is read through the secrets capability, so whatever bindings that needs are contributed by secrets — which turnstile depends on.

What it contributes is a validated config and one middleware you stack where you want it.

Stacked, never blanket

The middleware goes on a route:

app.post("/signup", turnstile(), handler)

Not on the app. That distinction is deliberate and worth arguing for.

A humanity check is expensive in the only currency that matters here — it costs a real person a moment of friction and a small chance of failing. Putting one in front of everything taxes every request to stop the three that are hostile. So it goes where the abuse is: signup, login, form submit, password reset. Routes where an unauthenticated stranger causes work.

It is also not an identity strategy. Turnstile tells you something was probably a human. It tells you nothing about which human. That is auth’s job, and conflating the two produces a system where passing a checkbox is treated as a credential.

Cooperating without importing

Here is the part worth stealing.

auth gates its magic-link and OTP routes with Turnstile when you have it. Those are exactly the routes that need it — an unauthenticated endpoint that sends mail to any address supplied is the single most abusable thing in a typical product.

But @pithy-sh/turnstile does not import @pithy-sh/auth, and @pithy-sh/auth does not import turnstile’s middleware directly either. Turnstile publishes its resolved config, including which surfaces you asked to protect and at what widget mode. auth reads that config off the composed capability and decides accordingly.

So:

  • Compose turnstile, and auth’s mail-sending routes get a gate.
  • Do not compose it, and they do not — and nothing errors, because nothing was reaching for a package that is not there.

Neither package knows about the other’s internals. This is the kit’s rule again — a capability may depend on a core seam but never on a sibling — and turnstile is the clearest example of it, because it has almost nothing else to look at.

Test keys, automatically

Cloudflare publishes test keys that always pass or always fail, for exactly the reason you would expect: you cannot solve a real widget in a headless test, and you should not want to.

Those are wired for you in dev and staging. You do not think about it, and you do not end up with a conditional in your handler that skips verification when NODE_ENV looks right — which is the version of this everybody writes and nobody removes.

What it does not do

It does not stop a determined attacker. Turnstile raises the cost of automation; it does not make automation impossible, and anyone who wants your form badly enough will get a human to fill it in.

It does not rate limit. A verified human can still submit two hundred times, and stopping that is a different mechanism.

And it does not replace validating what you were sent. Proving a human filled the form says nothing about whether the payload is well-formed — which is a separate gate entirely.

Why it is worth the five minutes

Because the alternative is not “no bot protection”. The alternative is that you notice the problem after your mail domain’s reputation has taken a hit from a few thousand magic links sent to addresses that never asked for them — and then you add exactly this, in a hurry, under worse conditions.

It costs one line in your config and one middleware on the three or four routes that need it. There is no state to migrate and nothing to maintain, which is the nicest thing you can say about a security control.