> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agg.market/llms.txt
> Use this file to discover all available pages before exploring further.

# Bot Protection

> Shield any user action with Cloudflare Turnstile challenges and multi-layer rate limiting

<Info>
  **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.
</Info>

## 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 theme={null}
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 theme={null}
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.

```typescript theme={null}
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.

```mermaid theme={null}
sequenceDiagram
    participant C as Client
    participant A as AGG 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 SIWE message signing...
```

### Example — SIWE with inline challenge handling

```typescript theme={null}
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.

<ParamField header="x-app-id" type="string" required>
  Your application ID.
</ParamField>

<ResponseField name="siteKey" type="string | null">
  The 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.
</ResponseField>

```bash theme={null}
curl https://api.agg.market/bot-protection/site-key \
  -H "x-app-id: $APP_ID"
```

```json theme={null}
{ "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.

<ParamField header="x-app-id" type="string" required>
  Your AGG app ID. Must match the app the API key was issued for.
</ParamField>

<ParamField header="x-app-api-key" type="string" required>
  Your app-scoped API key
</ParamField>

<ParamField body="turnstileToken" type="string" required>
  The token returned by the Turnstile widget. Maximum 2048 characters.
</ParamField>

```bash theme={null}
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:**

```typescript theme={null}
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:**

<Info>
  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](/recipes/server-api-keys).
</Info>

```typescript theme={null}
// 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);
}
```

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

## 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:

```html theme={null}
<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:

```tsx theme={null}
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:

```html theme={null}
<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.

```tsx theme={null}
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:

```tsx theme={null}
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/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

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

## Related

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/recipes/authentication">
    Full sign-in flow for SIWE, SIWS, email, and OAuth providers.
  </Card>

  <Card title="Token Refresh" icon="rotate" href="/recipes/token-refresh">
    Renew access tokens after a successful sign-in.
  </Card>
</CardGroup>
