Bot protection
An app with a public form, or with sign-in switched off, has an open door: anything a person can post, a script can post ten thousand times. That costs you records you have to clean up, sign-up emails you paid to send, and — if the app uses AI — inference on your Cloudflare bill.
Bot protection closes that door. A visitor who isn’t signed in clears one check, and stays cleared for 30 minutes. Reading is never checked. A signed-in user is never asked.
Turning it on
Section titled “Turning it on”Turn it on. That’s the whole setup.
Bot protection uses Cloudflare Turnstile, which is free and unmetered. Turning the capability on creates a Turnstile widget named after your project in your own Cloudflare account, and keeps its two keys where they belong:
| Key | Where it goes |
|---|---|
| Site key | Public. Served to the page, so the check knows which widget to run. |
| Secret key | Private. Stored encrypted, deployed as a secret on your own Worker. |
Neither is ever shown to you and neither is ever pasted. The check takes effect on the next Ship.
A Turnstile widget only runs on the domains listed on it, and your project has two —
the live app and its preview (the same name with -preview). Both are on the widget from
the moment it is created, so the check works in the console preview and in production
alike. The widget is yours: it appears under Turnstile in your Cloudflare dashboard, and
it is deleted with the project.
What is guarded
Section titled “What is guarded”Every write door the platform serves — a POST, PUT, PATCH or DELETE to any
/api/_… endpoint, plus /api/auth (sign-up, sign-in, password reset).
| Checked? | |
|---|---|
| A signed-out visitor creating a record, uploading a file, calling AI | yes |
| Anyone signing up or signing in | yes |
| Reading anything at all | no |
| A signed-in user, doing anything | no |
| Routes the app serves itself, from its own server file | no |
| Stripe’s webhook | no |
| An outside service calling one of your Webhooks | no |
The last three are worth saying plainly. Routes your app writes itself are yours to guard; the check runs on the platform’s doors, not on your own. Stripe calls the payment webhook machine-to-machine, so it is exempt by design — it is verified by signature instead. A Webhooks delivery is the same story: a machine can’t solve a Turnstile challenge, so the bot check never stands in front of a hook — it’s verified by its own unguessable URL instead.
The API
Section titled “The API”| Method & path | Body | Returns |
|---|---|---|
GET /api/_guard |
— | the site key the check needs (guard() calls this for you) |
POST /api/_guard |
{ "token": "<from the check>" } |
{ ok: true } and the cookie that opens the write doors |
You don’t call these. The app imports one function and awaits it:
import { guard } from "guard";
await guard();That loads the check, runs it, and exchanges the result for a short-lived cookie. Calling it again while the visitor is still cleared costs nothing, so it is safe to await before anything that writes.
A stranger who hasn’t cleared it gets:
{ "error": "…", "guard": true }with a 403. That "guard": true is the signal to await guard() and retry.
What people see
Section titled “What people see”Usually nothing. Turnstile decides on its own whether a visitor needs to do anything, and most of the time it clears them silently in well under a second. When it does need interaction, a small box appears in the middle of the page and disappears once it’s done.
There is nothing for you to design, and nothing for the agent to build — asking for a
checkbox, or for a Turnstile widget on the page, gets you a second broken widget. One call
to guard() is the whole integration.