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

# Captcha

> Add Cloudflare Turnstile captcha protection to your React Native and Expo auth flows.

## Overview

[Captcha protection](/features/authentication/captcha) is enabled once, at the organization level, in the Turnkey Dashboard. Once it's on, **every signup and every OTP send** must carry a Cloudflare Turnstile token or the Auth Proxy rejects it.

React Native and Expo apps render the widget and attach tokens themselves. `@turnkey/react-native-wallet-kit` does not render Turnstile for you, but its `useTurnkey()` methods forward their params straight to `@turnkey/core`, so you pass `captchaToken` to them exactly as the examples below pass it to a core client.

<Warning>
  Captcha support requires `@turnkey/core` version **2.4.0** or later. Earlier versions have no way to attach a captcha token to a request.
</Warning>

<Warning>
  Captcha support requires `@turnkey/core` version **2.4.0** or later. Earlier versions have no way to attach a captcha token to a request.
</Warning>

There are three things to do: fetch your site key, render the Turnstile widget, and pass the token it produces into the SDK methods that sign up users or send OTPs.

<Warning>
  As soon as captcha is enabled in the Dashboard, unprotected clients start failing. Ship your integration first, since it stays dormant until Turnkey returns a site key, then enable the Dashboard toggle afterward for a clean cutover.
</Warning>

## Which requests are protected

Turnkey's Auth Proxy enforces captcha on two endpoints (and their `_v2` variants), and the token travels as an `X-Captcha-Token` header:

| Endpoint                          | Triggered by                    |
| --------------------------------- | ------------------------------- |
| `/v1/otp_init`, `/v1/otp_init_v2` | Sending an email or SMS OTP     |
| `/v1/signup`, `/v1/signup_v2`     | Creating a new sub-organization |

Everything else, including OTP verification and logins for accounts that already exist, is unprotected, so those calls never need a token.

Signup requests that already carry a verification token are exempt even on a protected endpoint, because the user passed captcha when the OTP was sent and the verification token is single-use.

### Failure modes

| Situation                         | Result                                                                                |
| --------------------------------- | ------------------------------------------------------------------------------------- |
| Captcha enabled, no token sent    | Request rejected: `X-Captcha-Token header is required when captcha is enabled`        |
| Token rejected by Turnstile       | Request rejected: `captcha verification failed`                                       |
| Turnstile unreachable or erroring | Request proceeds; verification fails open so a Cloudflare outage can't lock users out |

## Step 1: Fetch your site key

Turnkey provisions the Turnstile widget for you, so the site key comes from the Auth Proxy rather than your own Cloudflare account:

```ts theme={"system"}
import { getClientParams } from "@turnkey/core";

const clientParams = await getClientParams(
  "YOUR_AUTH_PROXY_CONFIG_ID",
  // Optional: custom auth proxy URL (defaults to https://authproxy.turnkey.com)
);

const turnstileSiteKey = clientParams.turnstileSiteKey;
```

`turnstileSiteKey` is present only when captcha is both enabled for your organization and released to it. When it's `undefined`, skip the widget entirely and omit `captchaToken` everywhere. Requests behave exactly as before.

<Note>
  Call `getClientParams` once during app initialization, alongside `client.init()`, and cache the result. There's no need to re-fetch it per auth attempt.
</Note>

<Note>
  Captcha is in Early Access and is additionally gated per organization. If you've flipped the Dashboard toggle on but `getClientParams` still returns no site key, your organization hasn't been enabled for the feature yet, so reach out to Turnkey. The Auth Proxy won't enforce captcha in this state either, so your auth flows keep working.
</Note>

## Step 2: Render the widget

Turnkey configures the widget in Cloudflare's **Managed** mode, so Turnstile decides per visitor whether an interactive challenge is needed. Render it with `appearance: "interaction-only"` so it stays hidden for the overwhelming majority of users and only appears when Cloudflare asks for interaction. This is what the React wallet kit does. Use the `onBeforeInteractive` callback to reveal a short prompt ("Let us know you're human") just before it appears, so the widget doesn't materialize unexplained.

Mount the widget when your auth screen opens rather than when the user submits. Turnstile then solves in the background and a token is usually waiting by the time you need one.

Turnstile is a browser widget with no native mobile SDK, so a React Native integration has to render it inside a WebView. [`react-native-turnstile`](https://www.npmjs.com/package/react-native-turnstile) wraps that for you:

```bash theme={"system"}
npm install react-native-turnstile react-native-webview
```

```bash Expo theme={"system"}
npx expo install react-native-webview
npm install react-native-turnstile
```

<Warning>
  `react-native-turnstile` loads the widget from a relay domain hosted by the package author (`turnstile.1337707.xyz`), because Turnstile's cookies are incompatible with `react-native-webview` directly. That domain must be permitted on the Turnstile widget, and Turnkey owns that configuration, not you. Contact Turnkey before relying on this package in production. If you'd rather not route challenges through a third-party domain, host an equivalent page on a domain you control and point a `react-native-webview` at it instead.
</Warning>

```tsx theme={"system"}
import { useEffect, useRef, useState } from "react";
import { Text, View } from "react-native";
import ReactNativeTurnstile, { resetTurnstile } from "react-native-turnstile";
import { getClientParams } from "@turnkey/core";

// Cached at module scope so it persists across mounts and components — fetched once per session
let cachedSiteKey: Promise<string | null> | undefined;

function getSiteKey(authProxyConfigId: string): Promise<string | null> {
  cachedSiteKey ??= getClientParams(authProxyConfigId).then(
    (params) => params.turnstileSiteKey ?? null,
  );
  return cachedSiteKey;
}

export function useCaptcha(authProxyConfigId: string) {
  const resetRef = useRef<() => void>(() => {});
  const [siteKey, setSiteKey] = useState<string | null>(null);
  const [captchaToken, setCaptchaToken] = useState<string | null>(null);
  const [showPrompt, setShowPrompt] = useState(false);

  useEffect(() => {
    getSiteKey(authProxyConfigId).then(setSiteKey);
  }, [authProxyConfigId]);

  // Spread the result into any SDK call that needs a token
  const consumeToken = () => {
    const token = captchaToken;
    setCaptchaToken(null);
    resetTurnstile(resetRef);
    return token ? { captchaToken: token } : {};
  };

  // Renders nothing when captcha is not enabled for this organization
  const widget = siteKey ? (
    <View>
      {showPrompt && <Text>Let us know you're human</Text>}
      <ReactNativeTurnstile
        sitekey={siteKey}
        resetRef={resetRef}
        appearance="interaction-only"
        size="normal"
        onVerify={(token) => setCaptchaToken(token)}
        onError={() => setCaptchaToken(null)}
        onExpire={() => setCaptchaToken(null)}
        onBeforeInteractive={() => setShowPrompt(true)}
      />
    </View>
  ) : null;

  return { widget, consumeToken };
}
```

<Tip>
  The site key is stable, so fetch it once and cache it rather than on every auth attempt.
</Tip>

Then mount the widget on your auth screen and consume tokens from the same hook:

```tsx theme={"system"}
import { Button, View } from "react-native";
import { OtpType } from "@turnkey/core";

function LoginScreen({ client }) {
  const { widget, consumeToken } = useCaptcha("YOUR_AUTH_PROXY_CONFIG_ID");

  const sendCode = async () => {
    const otpId = await client.initOtp({
      otpType: OtpType.Email,
      contact: "user@example.com",
      ...consumeToken(),
    });
    // ...navigate to your OTP entry screen with otpId...
  };

  return (
    <View>
      <Button title="Send code" onPress={sendCode} />
      {widget}
    </View>
  );
}
```

<Note>
  Two React Native differences from the web wrapper: `size` accepts only `"normal"` or `"compact"` (there's no `"flexible"`), and you reset via the `resetTurnstile(resetRef)` helper rather than a method on the widget ref.
</Note>

## Step 3: Consume and pass tokens

Each token is single-use. After a request consumes one, clear your stored token and reset the widget so a fresh token is ready for the next request:

<CodeGroup>
  ```ts Vanilla JS theme={"system"}
  function consumeToken() {
    const token = captchaToken;
    captchaToken = null;
    turnstile.reset("#turnstile-container");
    return token ? { captchaToken: token } : {};
  }
  ```

  ```ts React theme={"system"}
  const consumeToken = () => {
    const token = captchaToken;
    setCaptchaToken(null);
    turnstileRef.current?.reset();
    return token ? { captchaToken: token } : {};
  };
  ```

  ```ts React Native theme={"system"}
  const consumeToken = () => {
    const token = captchaToken;
    setCaptchaToken(null);
    resetTurnstile(resetRef);
    return token ? { captchaToken: token } : {};
  };
  ```
</CodeGroup>

Returning an object (`{ captchaToken }` or `{}`) lets you spread the result into SDK params, so the field is simply absent when captcha is disabled or no token is available.

Because a token may not have arrived yet at the moment the user taps, the React wallet kit polls for up to 5 seconds before giving up and sending the request without one. If you'd rather not wait, disable your auth buttons until a token exists and re-enable them from the widget's success callback.

### Passing tokens to SDK methods

<CodeGroup>
  ```ts Email OTP theme={"system"}
  import { TurnkeyClient, OtpType } from "@turnkey/core";

  const client = new TurnkeyClient({
    organizationId: "YOUR_ORG_ID",
    authProxyConfigId: "YOUR_AUTH_PROXY_CONFIG_ID",
  });
  await client.init();

  // Captcha is required here, since this sends the code
  const otpId = await client.initOtp({
    otpType: OtpType.Email,
    contact: "user@example.com",
    ...consumeToken(),
  });

  // ...user enters the code...

  // No captcha token needed: completeOtp carries the verification token
  // issued by verifyOtp, which the backend accepts in place of a challenge
  const session = await client.completeOtp({
    otpId,
    otpCode: "123456",
    contact: "user@example.com",
    otpType: OtpType.Email,
  });
  ```

  ```ts Passkey signup theme={"system"}
  const session = await client.signUpWithPasskey({
    ...consumeToken(),
  });
  ```

  ```ts Wallet theme={"system"}
  // Consumes the token only if this turns out to be a signup
  const session = await client.loginOrSignupWithWallet({
    walletProvider,
    ...consumeToken(),
  });
  ```

  ```ts OAuth theme={"system"}
  const session = await client.completeOauth({
    oidcToken,
    publicKey,
    providerName: "Google",
    ...consumeToken(),
  });
  ```
</CodeGroup>

### Methods that accept `captchaToken`

| Method                    | Notes                                                                                |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `initOtp`                 | Always sends the token; this is the OTP-send challenge                               |
| `signUpWithPasskey`       | Forwarded to signup                                                                  |
| `signUpWithOtp`           | Forwarded to signup                                                                  |
| `signUpWithOauth`         | Forwarded to signup                                                                  |
| `completeOauth`           | Forwarded only when the flow resolves to a signup                                    |
| `completeOtp`             | Forwarded only when the flow resolves to a signup; not needed in practice, see below |
| `loginOrSignupWithWallet` | Forwarded only when the flow resolves to a signup                                    |

Login-only methods (`verifyOtp`, `loginWithOtp`, `loginWithPasskey`, `loginWithOauth`, and `loginWithWallet`) take no captcha token at all.

<Note>
  `completeOtp` accepts a `captchaToken`, but you don't need to supply one. Email and phone signups that carry a verification token (which `completeOtp` obtains from `verifyOtp`) are exempt from the challenge, because the user already passed captcha when the code was sent. `@turnkey/react-wallet-kit` does not send a token here.
</Note>

## OAuth redirects

OAuth signups are challenged, but the token is generated *before* the user leaves your app for the provider. Encode the captcha token into the OAuth `state` parameter along with your other state (public key, session key, nonce), then read it back on return and pass it to `completeOauth`. This is what `@turnkey/react-wallet-kit` does for both its popup and redirect flows.

## Important considerations

* **One challenge per OTP flow.** Only `initOtp` needs a token. Resending a code is another `initOtp` call, so it needs a fresh one too.
* **Deploy first, enable second.** With no `turnstileSiteKey` returned from `getClientParams`, the widget stays dormant and `consumeToken()` returns `{}`, so it's safe to ship ahead of the Dashboard toggle.
* **Reset after every use.** Tokens are single-use; always reset the widget so the next one is pre-warmed.
* **Handle expiration.** Turnstile tokens expire after about five minutes. Clear your stored token on the expiry and error callbacks so you never submit a stale one.

## Related

* [Captcha protection](/features/authentication/captcha)
* [Email and SMS OTP authentication](/solutions/embedded-wallets/integration-guide/react-native/authentication/email-sms)
* [Captcha with `@turnkey/core`](/solutions/embedded-wallets/integration-guide/typescript/captcha)
* [Auth Proxy](/features/authentication/auth-proxy)
