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

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.
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 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 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.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.

SDK
// 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:

  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.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/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 sufwan-local-test 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