A practical guide to bot protection on unauthenticated pages. We will cover the widget, tokens, siteverify, and where to enforce it in your backend.
Introduction
Public pages are convenient for users, and convenient for bots too.
When a React app calls APIs without authentication (landing flows, migration wizards, open forms), you are exposing endpoints that scrapers, scripts, and abuse tools can hit directly. Rate limits help, but they do not stop distributed or slow automation that stays under the threshold.
Cloudflare Turnstile is a alternative to heavy CAPTCHAs. It is a small widget, often invisible, that issues a short-lived token. Your server then validates that token with Cloudflare before running any business logic.
This post walks through how Turnstile works, how we applied it to a real public routing page, and the decisions that matter when you want to protect anonymous APIs without breaking the user experience.
The problem: public does not mean safe
Here are the typical risks on anonymous endpoints:
Scraping - bulk reads of reference data that is only meant for real users
Enumeration - guessing IDs or client names by firing sequential requests
Overload - high request volume with no session or identity attached
Scripted abuse - automated form submissions with no real user behind them
Traditional CAPTCHAs work, but they hurt UX and accessibility. Turnstile aims to block bots while keeping friction low. Most users never see a puzzle at all.
Turnstile
| Term | Meaning |
|---|---|
| Site key | Public key in your frontend. Safe to expose since it only identifies your widget. |
| Secret key | Private key for server validation only. Lives in environment config or a secrets manager and never in the browser. |
| Token | One-time string from the widget, sent with API requests as a header. |
| siteverify | HTTP endpoint where your server asks Cloudflare if a token is valid. |
The flow is simple: widget issues a token, your frontend sends it as a header, your backend calls siteverify, and if it comes back as success: true then you run your business logic.
The trust rule is: never trust the browser, the frontend app, or the header value by itself. Only trust a successful siteverify response that was verified with your secret key on the server.
Tokens are single-use and short-lived. Each protected API call needs a fresh token, so your frontend must reset the widget before making a second call, even within the same session.
Turnstile is not a login system. It does not replace OAuth or your auth provider. It only reduces automated traffic on public, anonymous routes.
How does the widget know if it is human?
Your frontend does not create the token. Cloudflare does, after running a challenge in the browser.
What happens on the client side
The page loads the Turnstile script from
challenges.cloudflare.com, not from your servers.The script runs a challenge tied to your site key and hostname. The hostname must be allow-listed in the Cloudflare dashboard.
Cloudflare evaluates browser and session signals like automation indicators, environment fingerprints, and behavioural patterns from its bot-management systems.
If the session looks automated, no usable token is issued. The user may see an interactive challenge instead.
If it passes, Cloudflare returns an opaque token. Your frontend then sends it on the next API request.
Most real users see little or no visible UI. A checkbox or puzzle only appears when Cloudflare needs more confidence about the session.
Why attackers cannot fake a token
| Attack attempt | Why it fails |
|---|---|
Send a random string as cf-turnstile-response
|
siteverify rejects it because no such token was ever issued |
| Reuse a token copied from DevTools | The second verify call returns timeout-or-duplicate
|
| Call the API directly without loading the widget | There is no valid token to submit |
| Use a token from another site's widget | It fails the hostname and secret-key binding check at siteverify |
The token is not a JWT you validate locally. It is a one-time credential that only Cloudflare can confirm, and only when you provide your secret key from the server side.
Two layers of protection
| Layer | Who decides | Role |
|---|---|---|
| Widget (client) | Cloudflare challenge in the browser | Stops casual bots and only issues a token when the session passes |
| siteverify (server) | Cloudflare + your secret key | The real gate before business logic runs. Enforces expiry, hostname, and single use. |
One thing worth calling out: widget success is probabilistic, not a guarantee. A determined attacker with a real browser or advanced automation may still get through occasionally. That is why Turnstile should always be paired with rate limits and monitoring. It is not meant to be your only control.
Architecture
The frontend loads the widget and attaches the token header on each protected request. The backend calls siteverify before running any business logic and rejects the request if it fails.
End-to-end flow
User opens a public page.
The frontend loads the Turnstile widget using the site key from
VITE_TURNSTILE_SITE_KEY.Cloudflare runs a challenge in the browser and returns an opaque token.
The frontend calls the backend api with the header
cf-turnstile-response: <token>.The backend posts to siteverify with the secret and the token.
If
success: truecomes back and the hostname matches, the backend processes the request and returns data.Before a second action like a form submit, the frontend resets the widget to get a fresh token. The first one was already consumed in step 5.
The siteverify request
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
Content-Type: application/x-www-form-urlencoded
secret=<SECRET_KEY>&response=<TOKEN_FROM_CLIENT>
Always check success, verify that hostname matches your app domain, and handle error codes like timeout-or-duplicate explicitly in your code.
Frontend implementation (React)
// 1. Load the widget on the public page
<Turnstile
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
onSuccess={(token) => setToken(token)}
/>
// 2. Attach the token on each protected request
const fetchOptions = await fetch(`.../api`, {
headers: { 'cf-turnstile-response': token }
});
// 3. Before a second call, reset to get a fresh token
turnstileRef.current.reset();
const freshToken = await waitForToken(); // resolves from the onSuccess callback
const fetchStatus = await fetch(`.../api`, {
headers: { 'cf-turnstile-response': freshToken }
});
Backend implementation (Node.js / Express example)
async function verifyTurnstileToken(token: string, ip?: string): Promise<boolean> {
const body = new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY!,
response: token,
...(ip && { remoteip: ip }),
});
const res = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{ method: 'POST', body }
);
const data = await res.json();
// Check hostname too, not just success
return data.success && data.hostname === process.env.APP_HOSTNAME;
}
// Middleware applied to all anonymous routes
app.use('/api/public', async (req, res, next) => {
const token = req.headers['cf-turnstile-response'] as string;
if (!token) return res.status(403).json({ error: 'Missing Turnstile token' });
const valid = await verifyTurnstileToken(token, req.ip);
if (!valid) return res.status(403).json({ error: 'Invalid or expired token' });
next();
});
Operational checklist
| Topic | Guidance |
|---|---|
| Failure responses | Missing or invalid token should return 403. If siteverify is down, return 503. Do not silently swallow either case. |
| Logging | Log success and error-codes from each siteverify call. Never log the full token value. |
| CORS | Allow the cf-turnstile-response header from your frontend origins in your CORS config. |
| CI / testing | Use Cloudflare's published test site keys in CI. Mock the widget in unit tests so it is a seam you control. |
| Hostnames | Register localhost, your staging URL, and each production hostname in the Cloudflare dashboard. A mismatch will cause siteverify to fail. |
| Rate limits | Always combine with server-side rate limiting. Turnstile reduces automated traffic but rate limits handle volume from anything that slips through. |
FAQ
Can attackers forge a token?
They can put anything in that header. But without a real token that Cloudflare issued, siteverify comes back as success: false. The security lives entirely on the server side with your secret key, not in the client behaving honestly.
Can they replay a token captured from DevTools?
No. After the first successful siteverify call, any reuse fails with timeout-or-duplicate. This is why each API call needs its own fresh token.
Can someone skip the frontend and call the backend directly?
Yes, they can. That is exactly why siteverify has to run on the server for every anonymous route. If you only protect the frontend flow, it provides no real security.
Is the site key secret?
No, it is public and it is fine in your frontend code. The secret key is the one that must never leave the server.
Does this replace my auth system?
No. Turnstile is a bot check for anonymous traffic. Your authentication layer for identified users stays exactly as it is.
Will users see a CAPTCHA every time?
Usually not. Managed mode is often fully invisible. A checkbox or puzzle only shows up when Cloudflare is less confident about the session.
Do I still need rate limits if I have Turnstile?
Yes. Always use both. Turnstile is not a replacement for throttling.
Things to decide before rolling out
| # | Decision | Recommendation |
|---|---|---|
| 1 | Header name | Use cf-turnstile-response since it matches the Cloudflare docs |
| 2 | Where to verify | On every backend route that accepts anonymous traffic |
| 3 | Rate limits | Required alongside Turnstile, not optional |
| 4 | What to log | Log success and error-codes but never the full token value |
References
Turnstile does not prove who the user is. It just raises the cost for bots to get a valid token. Pair it with rate limits, keep your secret key server-side, and run siteverify on every anonymous route. That is the whole pattern.


Top comments (0)