Bot Protection
Shield any user action with Cloudflare Turnstile challenges and multi-layer rate limiting
Bot protection is enabled by default on every new app. A Cloudflare Turnstile widget is
provisioned automatically at app-creation time (provided the app has at least one
allowedOrigin), so you can start using it immediately — no setup required. This guide
covers the full protection model, the standalone /bot-protection/* endpoints you can use
to gate any action in your product, and how to render the Turnstile widget in your frontend.
Overview
sufwan-local-test combines global identity quotas, scoped read quotas, sign-in limits, and Turnstile challenges. Handle these response shapes in your client:
429 Too Many Requests— back off and retry after the server-provided delay.503 Service Unavailable— scoped rate-limit enforcement is unavailable; honor its retry hint.challenge_required— render a Turnstile widget, collect the token, and resubmit.
| Layer | Purpose | Client-visible outcome |
|---|---|---|
| 1 | Per-IP rate limit across every endpoint | 429 Too Many Requests |
| 2 | Per-user-in-app or per-app/IP identity limit | 429 Too Many Requests |
| 3 | Tighter rate limit on POST /auth/start | 429 Too Many Requests |
| 4 | Cloudflare Turnstile challenge on sign-in | challenge_required → 403 on invalid token |
Market-data reads also have automatic route-family quotas, including an outcome-unit budget shared by the single and batch outcome snapshot routes.
Layers 1–3 are automatic: they run before your request reaches any handler and return a
standard 429 you can retry. Layer 4 is the Turnstile challenge, which you integrate once
and reuse anywhere in your product.
Rate limits
Layer 1 — Per-IP limit
| Property | Value |
|---|---|
| Scope | All endpoints |
| Default | 1,000 requests / 60 seconds per IP |
| Response | 429 Too Many Requests with Retry-After header (seconds) |
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json
{ "message": "Too many requests. Retry after 37s." }Layer 2 — Browser identity or validated API key
| Property | Value |
|---|---|
| Scope | Authenticated app/user identity, or app/IP identity for anonymous browser requests |
| Default | 18,000 requests / 60 seconds per identity |
| Response | 429 Too Many Requests with Retry-After header |
This is not a shared limit for every user of an app. Browser requests must satisfy both the IP and identity limits. A validated API key uses its own limit instead, with an 18,000-request default or a configured per-key quota, and is exempt from the browser IP limit. New market-data route caps still apply to that key and are not bypassed by a higher per-key quota.
If scoped market-data limits cannot be enforced, those routes return 503 with Retry-After: 1 and Cache-Control: private, no-store; retry after the hint.
Layer 3 — Sign-in limit
A tighter per-IP limit applied only to POST /auth/start. Authentication endpoints are
high-value targets for credential stuffing, nonce mining, and email enumeration, so they
warrant stricter throttling.
| Property | Value |
|---|---|
| Scope | POST /auth/start only |
| Default | 20 requests / 60 seconds per IP |
| Response | 429 Too Many Requests with retryAfter in the response body |
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"message": "Too many requests. Retry after 12s.",
"retryAfter": 12
}Handling 429 on the client
Always honor the server's retry hint: Retry-After header for Layers 1 and 2, retryAfter
body field for Layer 3. Never hardcode a fixed delay.
async function authStartWithBackoff(
body: AuthStartBody,
attempt = 0,
): Promise<AuthStartResponse> {
try {
return await client.authStart(body);
} catch (err) {
if (isRateLimitError(err) && attempt < 3) {
const waitMs = (err.retryAfter ?? 2 ** attempt) * 1000;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return authStartWithBackoff(body, attempt + 1);
}
throw err;
}
}A burst of 429 responses during normal use typically indicates a tight retry loop in your
client. Debounce the caller and surface a visible "please wait" state after the second
consecutive 429.
Turnstile challenges
Layer 4 activates when all of the following are true:
- The sign-in provider is
siwe,siws, oremail(OAuth providers are exempt). - The app has
botProtectionEnabled = true. - The app's registered user count is at or above
botProtectionThreshold. - A Turnstile widget is linked to the app.
When activated, POST /auth/start returns { type: "challenge_required", siteKey } instead
of the usual nonce or magic link. Your client renders the Turnstile widget, collects the
resulting token, and retries /auth/start with turnstileToken included in the body.
sequenceDiagram
participant C as Client
participant A as sufwan-local-test API
participant CF as Cloudflare
C->>A: POST /auth/start { provider: "siwe" }
A-->>C: 200 { type: "challenge_required", siteKey }
C->>CF: render widget + user solves
CF-->>C: turnstileToken
C->>A: POST /auth/start { provider: "siwe", turnstileToken }
A->>CF: verify token
CF-->>A: valid
A-->>C: 200 { type: "nonce", nonce }
Note over C,A: continue the sign-in on Quickstart RESTWhen type is challenge_required, render the widget and send the same start again with turnstileToken. Then continue Quickstart: REST. The same retry applies to siws and email. Wallet message signing stays on that quickstart.
Response matrix — POST /auth/start
| Scenario | HTTP | Body |
|---|---|---|
| Below threshold, or bot protection disabled | 200 | { type: "nonce" | "magic_link" | ... } |
Challenge required, no turnstileToken submitted | 200 | { type: "challenge_required", siteKey } |
Challenge required, valid turnstileToken | 200 | { type: "nonce" | "magic_link" | ... } |
| Challenge required, invalid or reused token | 403 | { message: "Bot protection verification failed." } |
| Rate limit breached | 429 | { message: "Too many requests. Retry after Xs.", retryAfter } |
Standalone bot protection
Use the same widget to gate any action. Two calls: fetch the site key from the browser, then verify the token from your server.
curl https://api.t.sufwankhalid.com/bot-protection/site-key \
-H "x-app-id: $APP_ID"Verify requires both x-app-id and x-app-api-key. The server checks that x-app-id matches the app id embedded in the key. An API key alone is not enough. Tokens are single-use.
// No SDK method. Call this from your server, never from the browser.
const verifyRes = await fetch("https://api.t.sufwankhalid.com/bot-protection/verify", {
method: "POST",
headers: {
"x-app-id": process.env.AGG_APP_ID!,
"x-app-api-key": process.env.AGG_APP_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ turnstileToken }),
});Request fields are on Verify Turnstile token.
Example — gating any action
Site key lookup happens in the browser. Token verification happens on your server, with both headers.
Frontend — collect the token and post it to your own backend:
async function submitGatedAction(payload: ActionPayload) {
// 1. Fetch the site key once — safe to call from the browser with just x-app-id.
const { siteKey } = await fetch("https://api.t.sufwankhalid.com/bot-protection/site-key", {
headers: { "x-app-id": APP_ID },
}).then((r) => r.json());
if (!siteKey) {
// No widget registered — proceed without a challenge.
return submit(payload);
}
// 2. Render the widget and collect a token.
const turnstileToken = await renderTurnstileWidget({ siteKey });
// 3. Post the token + payload to YOUR backend (never call /bot-protection/verify directly).
const res = await fetch("/api/my-gated-action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, turnstileToken }),
});
if (!res.ok) throw new Error("Bot protection verification failed");
return res.json();
}Need an API key, or the rules for x-app-api-key (scopes, rotation, requireApiKey, per-key limits)? See
Server API Keys.
Each Turnstile token can only be verified once. If you pre-verify a token against
/bot-protection/verify and then submit the same token to another endpoint, the second
call will fail. For any given user action, collect a fresh token from the widget before
submitting.
Rendering the Turnstile widget
Rendering the widget is a three-step process on the client:
- Load the Turnstile script — Cloudflare's loader (
api.js) exposes a globalwindow.turnstileobject with the widget API. - Mount the widget into a DOM element — pass the
siteKeyfetched fromGET /bot-protection/site-key(or thesiteKeyreturned in achallenge_requiredresponse). - Collect the token in a callback — the widget fires a callback with a short-lived token once the user passes the challenge. Submit that token alongside the user's action.
Step 1 — Load the Turnstile script
Add the loader to your document <head> once, on every page that might render a widget.
The async defer attributes keep it from blocking initial render:
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>In a React/Next.js app, load it once in your root layout (e.g. _app.tsx or layout.tsx)
using Next's <Script> component or a one-shot effect:
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
/>
{children}
</body>
</html>
);
}Step 2 — Declarative embed (simplest)
The quickest path is the declarative embed. Cloudflare's loader scans the DOM for any
element with class cf-turnstile and auto-renders a widget into it using the attributes
you provide:
<div
class="cf-turnstile"
data-sitekey="0x4AAAAAAA..."
data-callback="onTurnstileSolve"
data-error-callback="onTurnstileError"
data-expired-callback="onTurnstileExpire"
></div>
<script>
window.onTurnstileSolve = (token) => {
// Pass `token` into your submit handler — e.g. client.authStart({ turnstileToken: token })
document.querySelector("#signin-form").dataset.turnstileToken = token;
};
window.onTurnstileError = () => console.warn("Turnstile error — widget reset");
window.onTurnstileExpire = () => console.warn("Turnstile token expired — ask user to re-solve");
</script>Use this when the widget lives on a static page (a sign-in form, a contact form, etc.) and you're happy to read the token from a callback.
Step 3 — Programmatic rendering (React)
For SPAs — where the widget is mounted and unmounted as the user navigates — use the
programmatic API. Call window.turnstile.render() to mount, reset() when the user
retries, and remove() on unmount so the widget doesn't leak across route changes.
import { useEffect, useRef, useState } from "react";
declare global {
interface Window {
turnstile?: {
render: (
el: HTMLElement,
opts: {
sitekey: string;
callback: (token: string) => void;
"error-callback"?: () => void;
"expired-callback"?: () => void;
},
) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
}
}
export function useTurnstile(siteKey: string | null) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
if (!siteKey || !containerRef.current || !window.turnstile) return;
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (t) => setToken(t),
"expired-callback": () => setToken(null),
"error-callback": () => setToken(null),
});
return () => {
if (widgetIdRef.current) window.turnstile?.remove(widgetIdRef.current);
};
}, [siteKey]);
const reset = () => {
setToken(null);
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
};
return { containerRef, token, reset };
}Wire it into a form component:
function SignInForm() {
const [siteKey, setSiteKey] = useState<string | null>(null);
const { containerRef, token, reset } = useTurnstile(siteKey);
useEffect(() => {
fetch("https://api.t.sufwankhalid.com/bot-protection/site-key", { headers: { "x-app-id": APP_ID } })
.then((r) => r.json())
.then(({ siteKey }) => setSiteKey(siteKey));
}, []);
const handleSubmit = async () => {
if (!token) return; // widget hasn't produced a token yet
try {
await client.authStart({ provider: "siwe", turnstileToken: token });
} catch (err) {
reset(); // on failure, reset so the user can solve again
throw err;
}
};
return (
<form onSubmit={handleSubmit}>
<div ref={containerRef} />
<button type="submit" disabled={!token}>
Sign in
</button>
</form>
);
}Token lifetime and reset behavior
Tokens are short-lived and single-use:
- 5-minute TTL — Cloudflare invalidates a token 5 minutes after the user solves the challenge.
- Single-use — sufwan-local-test enforces one-time verification. A token consumed by
/auth/startor/bot-protection/verifycannot be reused. - Always collect a fresh token right before the submit action, not at page load.
- On a failed submit, call
turnstile.reset()so the user can produce a new token without a full page reload.
Cloudflare also fires expired-callback if the user leaves the widget idle too long —
handle it by clearing your local token state so the submit button re-disables until the
user re-solves.
Troubleshooting
| Symptom | Likely cause |
|---|---|
403 Bot protection verification failed. on first submit | Token expired (over 5 minutes since solve), or the widget domain is not registered for this app. |
403 on a follow-up submit with the same token | Token was already consumed — collect a fresh one from the widget. |
400 Bot protection is not configured for this app. | The app has no Turnstile widget linked. Contact sufwan-local-test support to configure bot protection for the app. |
429 on repeated /auth/start calls | Layer 3 sign-in rate limit. Back off and retry after the retryAfter interval. |
| Widget never renders in the browser | No widget is registered for the app — GET /bot-protection/site-key returns { siteKey: null }. |