Security

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

sufwantest 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.
LayerPurposeClient-visible outcome
1Per-IP rate limit across every endpoint429 Too Many Requests
2Per-user-in-app or per-app/IP identity limit429 Too Many Requests
3Tighter rate limit on POST /auth/start429 Too Many Requests
4Cloudflare Turnstile challenge on sign-inchallenge_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

PropertyValue
ScopeAll endpoints
Default1,000 requests / 60 seconds per IP
Response429 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

PropertyValue
ScopeAuthenticated app/user identity, or app/IP identity for anonymous browser requests
Default18,000 requests / 60 seconds per identity
Response429 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.

PropertyValue
ScopePOST /auth/start only
Default20 requests / 60 seconds per IP
Response429 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:

  1. The sign-in provider is siwe, siws, or email (OAuth providers are exempt).
  2. The app has botProtectionEnabled = true.
  3. The app's registered user count is at or above botProtectionThreshold.
  4. 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 sufwantest 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 REST

When 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

ScenarioHTTPBody
Below threshold, or bot protection disabled200{ type: "nonce" | "magic_link" | ... }
Challenge required, no turnstileToken submitted200{ type: "challenge_required", siteKey }
Challenge required, valid turnstileToken200{ type: "nonce" | "magic_link" | ... }
Challenge required, invalid or reused token403{ message: "Bot protection verification failed." }
Rate limit breached429{ 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.prod.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.

SDK
// No SDK method. Call this from your server, never from the browser.
const verifyRes = await fetch("https://api.prod.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.prod.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:

  1. Load the Turnstile script — Cloudflare's loader (api.js) exposes a global window.turnstile object with the widget API.
  2. Mount the widget into a DOM element — pass the siteKey fetched from GET /bot-protection/site-key (or the siteKey returned in a challenge_required response).
  3. 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.prod.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 — sufwantest enforces one-time verification. A token consumed by /auth/start or /bot-protection/verify cannot 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

SymptomLikely cause
403 Bot protection verification failed. on first submitToken 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 tokenToken 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 sufwantest support to configure bot protection for the app.
429 on repeated /auth/start callsLayer 3 sign-in rate limit. Back off and retry after the retryAfter interval.
Widget never renders in the browserNo widget is registered for the app — GET /bot-protection/site-key returns { siteKey: null }.

On this page