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
AGG ships a four-layer protection model. As an integrator, you only need to handle two response shapes in your client:
429 Too Many Requests— back off and retry after the server-provided delay.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-app rate limit across every endpoint | 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 |
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 | 100 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 — Per-app limit
| Property | Value |
|---|---|
| Scope | All endpoints that carry x-app-id |
| Default | 500 requests / 60 seconds per app |
| Response | 429 Too Many Requests with Retry-After header |
Layer 2 is sized for aggregate traffic across your entire user base, so it comfortably accommodates normal usage patterns.
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.
Example — SIWE with inline challenge handling
async function signInWithSiwe(
address: string,
signMessage: (msg: string) => Promise<string>,
) {
let start = await client.authStart({ provider: "siwe" });
if (start.type === "challenge_required") {
const turnstileToken = await renderTurnstileWidget({ siteKey: start.siteKey });
start = await client.authStart({ provider: "siwe", turnstileToken });
}
if (start.type !== "nonce") {
throw new Error(`Unexpected authStart response: ${start.type}`);
}
const message = client.buildSiweMessage({
address,
nonce: start.nonce,
domain: window.location.host,
uri: window.location.origin,
statement: "Sign in to the app",
chainId: 1,
});
const signature = await signMessage(message);
const { accessToken } = await client.verify({ message, signature });
return accessToken;
}
The same challenge-handling pattern applies to siws and email providers — only the
follow-up step after receiving the nonce or magic link differs.
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
The /bot-protection/* endpoints let you use the same Turnstile widget to gate any action
in your product you want to shield a sensitive action from automated abuse. The API surface is
exactly two endpoints: fetch the site key, verify the token.
GET /bot-protection/site-key
Returns the Turnstile site key registered for your app so you can render the widget in any part of your UI.
x-app-idstringrequiredYour application ID.
siteKeystring | nullThe Turnstile site key to pass into the widget as data-sitekey (or the equivalent prop in
a React/Vue wrapper). Returns null when no widget is registered for the app.
curl https://api.agg.market/bot-protection/site-key \
-H "x-app-id: $APP_ID"
{ "siteKey": "0x4AAAAAAA..." }
POST /bot-protection/verify
Verifies a Turnstile token against the app's linked widget. Tokens are single-use — a second verification of the same token always fails.
x-app-idstringrequiredYour AGG app ID. Must match the app the API key was issued for.
x-app-api-keystringrequiredYour app-scoped API key
turnstileTokenstringrequiredThe token returned by the Turnstile widget. Maximum 2048 characters.
curl -X POST https://api.agg.market/bot-protection/verify \
-H "x-app-id: $APP_ID" \
-H "x-app-api-key: $APP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "turnstileToken": "cf-token-here" }'
| Response | Meaning |
|---|---|
200 { "success": true } | Token is valid — proceed with the gated action. |
400 Bot protection is not configured for this app. | No widget is linked to the app. |
401 Invalid API key | Missing, malformed, revoked, or expired x-app-api-key, or x-app-id does not match the key's app. |
403 Invalid Turnstile token. | Empty, oversize, or malformed token. |
403 Bot protection verification failed. | Cloudflare rejected the token or it was already consumed. |
429 | Per-IP or per-app rate limit exceeded. |
Example — gating any action
Site key lookup happens in the browser (public, safe). Token verification happens on your server (protected by your API key).
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.agg.market/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();
}
Backend — verify the token with your API key before executing the action:
Need to create an API key or looking for the full reference on x-app-api-key
(scopes, rotation, requireApiKey lockout, per-key rate limits)? See
Server API Keys.
// Example: Node.js handler running on your server.
// process.env.AGG_APP_API_KEY is your app's secret API key — never expose it to the client.
export async function handleGatedAction(req, res) {
const { turnstileToken, ...payload } = req.body;
const verifyRes = await fetch("https://api.agg.market/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 }),
});
if (!verifyRes.ok) {
return res.status(403).json({ error: "Bot protection verification failed" });
}
// Verification passed — execute the gated action.
const result = await performAction(payload);
return res.json(result);
}
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
className="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("/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 — AGG 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. Re-enable via the operator endpoint, or contact AGG support. |
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 }. |