Cloudflare Turnstile guards the mailer, not the login screen
Turnstile went on signup, login, forgot-password, resend-verification and recover for one reason: those five endpoints can empty a free email quota or grind past a rate limit, and a CAPTCHA on the feed or the streak grid would only annoy people who never touch them.
Turnstile isn’t on the whole app. It’s on five endpoints: signup, login, forgot-password, resend-verification and recover. Every one of them can be hit by a script with no cost to the person running it, and every one of them either sends an email or tries a password.
What it’s actually stopping
Email is metered. The free tier of a transactional provider gives you a daily
send limit, and every signup, resend and password reset burns one. A script
that hits resend-verification in a loop doesn’t need to guess anything — it
just needs the endpoint to exist and be free to call. Long before it does
anything else, it’s spent the day’s email quota on nothing.
Login and recover are the other kind of problem: not quota, but attempts. Rate limits already exist per IP, but a botnet spreads the same attack across enough IPs that a per-IP limit stops mattering. Turnstile doesn’t replace the rate limit. It sits in front of it, so the traffic that would exhaust the limit mostly never reaches it.
Why not everywhere
Everything else — logging a session, ticking a habit, opening the week board — is authenticated, already rate-limited by the account it belongs to, and produces no email and no login attempt. Putting a challenge in front of those endpoints would slow down every real use of the app to solve a problem that doesn’t exist there. A guard belongs exactly where the thing it’s guarding against can happen, not everywhere out of caution.
One script, one exception
The app’s Content Security Policy is default-src 'self': no third-party
script, frame or connection anywhere, on principle. Turnstile is the one
deliberate exception, scoped to challenges.cloudflare.com and nowhere else
in _headers. It’s Cloudflare’s own product rather than a new vendor, which
is the only reason it earned the exception at all — and it stays app-only.
The marketing site and the blog stay untouched; neither one sends email or
accepts a password.
It also caught two build quirks before anyone else did: Astro inlining a
short <script>, and Vite emitting a small compiled asset as a base64
data: URI. Both are silently blocked by a script-src of 'self'. The fix
in both cases was assetsInlineLimit: 0 in the Vite config, not a looser
policy — the CSP was right, the build was wrong.
AlzyWelzy
Building PaceStreak. GitHub
Related
Security & PrivacyNotification buttons without a session
A habit reminder now has Done and Snooze buttons. The service worker that handles them has no access token, by design, so each button carries a signed, single-purpose link instead.
2 min
Security & PrivacyFixing the dead ends in sign-up and verification
Three real bugs found by using the OTP flow the way a person actually would: a stuck login, a false 'already registered' error, and a confirmation screen that didn't lead anywhere. Plus a status page that finally watches the whole stack.
3 min
ProductA coach made of rules
The new daily coach is a few if-statements over your own data. Why that beats a generated paragraph, and why the app shows only the notes its existing cards don't already cover.
1 min