Skip to content

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.

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.

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.

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.

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.