Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"solutions/embedded-wallets/integration-guide/react/index",
"solutions/embedded-wallets/integration-guide/react/getting-started",
"solutions/embedded-wallets/integration-guide/react/auth",
"solutions/embedded-wallets/integration-guide/react/captcha",
"solutions/embedded-wallets/integration-guide/react/using-embedded-wallets",
{
"group": "Using external wallets",
Expand Down Expand Up @@ -105,7 +106,8 @@
"solutions/embedded-wallets/integration-guide/react-native/authentication/overview",
"solutions/embedded-wallets/integration-guide/react-native/authentication/email-sms",
"solutions/embedded-wallets/integration-guide/react-native/authentication/passkey",
"solutions/embedded-wallets/integration-guide/react-native/authentication/social-logins"
"solutions/embedded-wallets/integration-guide/react-native/authentication/social-logins",
"solutions/embedded-wallets/integration-guide/react-native/authentication/captcha"
]
},
"solutions/embedded-wallets/integration-guide/react-native/sub-organization-customization",
Expand All @@ -120,6 +122,7 @@
"solutions/embedded-wallets/integration-guide/typescript/index",
"solutions/embedded-wallets/integration-guide/typescript/getting-started",
"solutions/embedded-wallets/integration-guide/typescript/auth",
"solutions/embedded-wallets/integration-guide/typescript/captcha",
"solutions/embedded-wallets/integration-guide/typescript/advanced-backend-authentication",
"solutions/embedded-wallets/integration-guide/typescript/advanced-api-requests"
]
Expand Down Expand Up @@ -336,7 +339,13 @@
"features/authentication/bring-your-own-auth"
]
},
"features/authentication/auth-proxy",
{
"group": "Auth Proxy",
"pages": [
"features/authentication/auth-proxy",
"features/authentication/captcha"
]
},
"features/authentication/sessions",
{
"group": "Advanced",
Expand Down
73 changes: 73 additions & 0 deletions features/authentication/captcha.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: "Captcha"
description: "Protect your auth flows from bots and abuse using Cloudflare Turnstile Captcha, enabled through the Turnkey Dashboard."
sidebarTitle: "Captcha"
tag: "Early Access"
---

Turnkey integrates [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) to add Captcha protection to authentication flows. When enabled, Turnstile presents a lightweight, user-friendly challenge that blocks automated abuse such as bots, credential-stuffing attacks, and signup spam, all without disrupting the experience for real users.

Captcha protection is enforced at the two entry points most vulnerable to abuse:

- **Requesting an email or SMS OTP**: captcha is required when the code is sent, covering both signup and login flows.
- **Creating a new account (sub-organization)**: captcha is required during signup via passkey, OAuth / social login, or external wallet.

Once a user has passed the captcha challenge when the OTP was sent, the subsequent OTP verification and login steps are not challenged again. They are protected by a one-time verification token instead. Existing-account logins via passkey, OAuth / social, or wallet are not captcha-challenged.

## Enabling Captcha

Captcha protection is configured at the organization level in the Turnkey Dashboard. Once enabled, it is automatically enforced for the protected flows, and no changes to your application code are required.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should specify this is only the case for react-wallet-kit users

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and make that super duper obvious


<Steps>
<Step title="Open your Embedded Wallets Configuration">
Log in to the [Turnkey Dashboard](https://app.turnkey.com) and navigate to **Configuration** for Embedded Wallets.
</Step>
<Step title="Find the Captcha toggle">
Locate the **Captcha** setting in the Auth Proxy section.

<Frame>
<img
src="/images/authentication/img/captcha-dashboard-toggle.png"
alt="Captcha protection toggle in the Turnkey Dashboard"
/>
</Frame>
</Step>
<Step title="Enable Captcha">
Toggle the setting **on** and save your changes. Captcha protection is now active for your organization.
</Step>
</Steps>

<Note>
Changes take effect immediately. Protected flows initiated through `@turnkey/react-wallet-kit` will display the Turnstile widget.
</Note>

## Integration guides

How much work Captcha takes depends on which SDK renders your auth UI:

| Your setup | What you do | Guide |
| --- | --- | --- |
| [`@turnkey/react-wallet-kit`](https://www.npmjs.com/package/@turnkey/react-wallet-kit) | Nothing. The widget and tokens are handled for you | [Captcha (React)](/solutions/embedded-wallets/integration-guide/react/captcha) |
| React Native or Expo | Render the widget in a WebView and pass tokens | [Captcha (React Native)](/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha) |
| [`@turnkey/core`](https://www.npmjs.com/package/@turnkey/core) directly (plain JavaScript, Vue, Svelte, Angular, custom React UI) | Render the widget and pass tokens | [Captcha with `@turnkey/core`](/solutions/embedded-wallets/integration-guide/typescript/captcha) |

In every case the mechanics are the same: Turnstile runs its challenge when a user initiates a protected flow, issues a token on success, and Turnkey verifies that token before creating any auth activity. Requests that fail the challenge are rejected outright.

## Protected auth methods

| Auth flow | Captcha enforced |
| --- | --- |
| Email OTP: sending the code (signup & login) | Yes |
| Phone (SMS) OTP: sending the code (signup & login) | Yes |
| New account signup via passkey | Yes |
| New account signup via OAuth / social login | Yes |
| New account signup via external wallet | Yes |

## Related

- [Captcha (React)](/solutions/embedded-wallets/integration-guide/react/captcha)
- [Captcha (React Native)](/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha)
- [Captcha with `@turnkey/core`](/solutions/embedded-wallets/integration-guide/typescript/captcha)
- [Auth Proxy](/features/authentication/auth-proxy)
- [Authentication overview](/features/authentication/overview)
- [React Wallet Kit: Getting started](/solutions/embedded-wallets/integration-guide/react/getting-started)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 61 additions & 0 deletions snippets/shared/captcha-core-basics.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<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
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>
Comment on lines +53 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Technically the dashboard toggle is hidden when the org hasn't been allow listed in the feature gate so customers should never find themselves in this state. Wouldn't be opposed to leaving this in though since it doesn't hurt to have


## 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.
128 changes: 128 additions & 0 deletions snippets/shared/captcha-core-tokens.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
## 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
function consumeToken() {
const token = captchaToken;
captchaToken = null;
turnstile.reset("#turnstile-container");
return token ? { captchaToken: token } : {};
}
```

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

```ts React Native
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
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
const session = await client.signUpWithPasskey({
...consumeToken(),
});
```

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

```ts OAuth
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>

<Warning>
`signUpWithWallet` exposes a `captchaToken` parameter but does not forward it, so the request will be rejected when captcha is enabled. Use `loginOrSignupWithWallet` for wallet signup flows.
</Warning>
Comment on lines +115 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uhhh this might be a bug in the SDK 😅 , we should prob fix that and remove this warning


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