diff --git a/docs.json b/docs.json index d573a462..a23346f1 100644 --- a/docs.json +++ b/docs.json @@ -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", @@ -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", @@ -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" ] @@ -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", diff --git a/features/authentication/captcha.mdx b/features/authentication/captcha.mdx new file mode 100644 index 00000000..f556ca23 --- /dev/null +++ b/features/authentication/captcha.mdx @@ -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. + + + + Log in to the [Turnkey Dashboard](https://app.turnkey.com) and navigate to **Configuration** for Embedded Wallets. + + + Locate the **Captcha** setting in the Auth Proxy section. + + + Captcha protection toggle in the Turnkey Dashboard + + + + Toggle the setting **on** and save your changes. Captcha protection is now active for your organization. + + + + + Changes take effect immediately. Protected flows initiated through `@turnkey/react-wallet-kit` will display the Turnstile widget. + + +## 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) diff --git a/images/authentication/img/captcha-dashboard-toggle.png b/images/authentication/img/captcha-dashboard-toggle.png new file mode 100644 index 00000000..b06df7e9 Binary files /dev/null and b/images/authentication/img/captcha-dashboard-toggle.png differ diff --git a/snippets/shared/captcha-core-basics.mdx b/snippets/shared/captcha-core-basics.mdx new file mode 100644 index 00000000..97ecffb0 --- /dev/null +++ b/snippets/shared/captcha-core-basics.mdx @@ -0,0 +1,61 @@ + + Captcha support requires `@turnkey/core` version **2.4.0** or later. Earlier versions have no way to attach a captcha token to a request. + + +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. + + + 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. + + +## 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. + + + Call `getClientParams` once during app initialization, alongside `client.init()`, and cache the result. There's no need to re-fetch it per auth attempt. + + + + 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. + + +## 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. diff --git a/snippets/shared/captcha-core-tokens.mdx b/snippets/shared/captcha-core-tokens.mdx new file mode 100644 index 00000000..6f2c4c57 --- /dev/null +++ b/snippets/shared/captcha-core-tokens.mdx @@ -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: + + + +```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 } : {}; +}; +``` + + + +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 + + + +```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(), +}); +``` + + + +### 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. + + + `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. + + + + `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. + + +## 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. diff --git a/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha.mdx b/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha.mdx new file mode 100644 index 00000000..dcad1a20 --- /dev/null +++ b/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha.mdx @@ -0,0 +1,121 @@ +--- +title: "Captcha" +description: "Add Cloudflare Turnstile captcha protection to your React Native and Expo auth flows." +sidebarTitle: "Captcha" +tag: "Early Access" +--- + +import CaptchaCoreBasics from "/snippets/shared/captcha-core-basics.mdx"; +import CaptchaCoreTokens from "/snippets/shared/captcha-core-tokens.mdx"; + +## 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. + + + +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 +npm install react-native-turnstile react-native-webview +``` + +```bash Expo +npx expo install react-native-webview +npm install react-native-turnstile +``` + + + `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. + + +```tsx +import { useEffect, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import ReactNativeTurnstile, { resetTurnstile } from "react-native-turnstile"; +import { getClientParams } from "@turnkey/core"; + +export function useCaptcha(authProxyConfigId: string) { + const resetRef = useRef<() => void>(() => {}); + const [siteKey, setSiteKey] = useState(null); + const [captchaToken, setCaptchaToken] = useState(null); + const [showPrompt, setShowPrompt] = useState(false); + + useEffect(() => { + getClientParams(authProxyConfigId).then((params) => { + if (params.turnstileSiteKey) { + setSiteKey(params.turnstileSiteKey); + } + }); + }, [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 ? ( + + {showPrompt && Let us know you're human} + setCaptchaToken(token)} + onError={() => setCaptchaToken(null)} + onExpire={() => setCaptchaToken(null)} + onBeforeInteractive={() => setShowPrompt(true)} + /> + + ) : null; + + return { widget, consumeToken }; +} +``` + +Then mount the widget on your auth screen and consume tokens from the same hook: + +```tsx +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 ( + + ; +} +``` + +## How it works + +1. When a user initiates a protected flow (sending an OTP or signing up for a new account), the Turnstile widget renders inside the auth UI. +2. Turnstile performs its challenge in the background. For most legitimate users, this is invisible or requires a single click. +3. On success, Turnstile issues a token that is passed along with the request to Turnkey's backend for verification. +4. Requests that fail the Captcha challenge are rejected before any auth activity is created. + +The widget runs in Cloudflare's **Managed** mode with `appearance: "interaction-only"`, so it stays hidden for the overwhelming majority of users and only appears when Cloudflare asks for interaction. A short "Let us know you're human" prompt is shown just before that happens. + +Because a token may not have arrived yet at the moment the user submits, the wallet kit polls for up to 5 seconds before giving up and sending the request without one. + + + OAuth signups are challenged before the user leaves your app for the provider. The wallet kit encodes the captcha token into the OAuth `state` parameter and reads it back on return, for both its popup and redirect flows. + + +## Building your own UI + +If you're building auth screens directly on `@turnkey/core` rather than using the wallet kit's UI, you render the widget and attach tokens yourself. See [Captcha with `@turnkey/core`](/solutions/embedded-wallets/integration-guide/typescript/captcha). + +## Related + +- [Captcha protection](/features/authentication/captcha) +- [Authentication](/solutions/embedded-wallets/integration-guide/react/auth) +- [Captcha with `@turnkey/core`](/solutions/embedded-wallets/integration-guide/typescript/captcha) +- [Auth Proxy](/features/authentication/auth-proxy) diff --git a/solutions/embedded-wallets/integration-guide/typescript/captcha.mdx b/solutions/embedded-wallets/integration-guide/typescript/captcha.mdx new file mode 100644 index 00000000..412bd7d5 --- /dev/null +++ b/solutions/embedded-wallets/integration-guide/typescript/captcha.mdx @@ -0,0 +1,116 @@ +--- +title: "Captcha" +description: "Add Cloudflare Turnstile captcha protection to your auth flows when building your own UI with @turnkey/core." +sidebarTitle: "Captcha" +tag: "Early Access" +--- + +import CaptchaCoreBasics from "/snippets/shared/captcha-core-basics.mdx"; +import CaptchaCoreTokens from "/snippets/shared/captcha-core-tokens.mdx"; + +## 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. + +[`@turnkey/react-wallet-kit`](/solutions/embedded-wallets/integration-guide/react/captcha) renders the widget and attaches tokens for you, so React web apps using it need no integration work. This page is for you if you're building your own auth UI with `@turnkey/core`: + +- Plain JavaScript or TypeScript web apps +- React web apps built directly on `@turnkey/core` +- Vue, Svelte, Angular, and other frameworks + +React Native and Expo apps follow the same three steps, with a WebView-based widget. See [Captcha (React Native)](/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha) for that walkthrough. + + + +### Vanilla JavaScript + +Add the Turnstile script: + +```html + +``` + +Then render it and hold onto the token: + +```html +
+ + +``` + +### React (web) + +`@turnkey/react-wallet-kit` uses [`@marsidev/react-turnstile`](https://github.com/marsidev/react-turnstile) internally, so it's the wrapper we recommend for React apps built on core: + +```bash +npm install @marsidev/react-turnstile +``` + +```tsx +import { useEffect, useRef, useState } from "react"; +import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; +import { getClientParams } from "@turnkey/core"; + +function CaptchaWidget({ authProxyConfigId }: { authProxyConfigId: string }) { + const turnstileRef = useRef(null); + const [siteKey, setSiteKey] = useState(null); + const [captchaToken, setCaptchaToken] = useState(null); + const [showPrompt, setShowPrompt] = useState(false); + + useEffect(() => { + getClientParams(authProxyConfigId).then((params) => { + if (params.turnstileSiteKey) { + setSiteKey(params.turnstileSiteKey); + } + }); + }, [authProxyConfigId]); + + // Captcha is not enabled for this organization, so render nothing + if (!siteKey) return null; + + return ( + <> + {showPrompt &&

Let us know you're human

} + setCaptchaToken(token)} + onError={() => setCaptchaToken(null)} + onExpire={() => setCaptchaToken(null)} + onBeforeInteractive={() => setShowPrompt(true)} + options={{ + appearance: "interaction-only", + size: "flexible", + }} + /> + + ); +} +``` + + + +## Related + +- [Captcha protection](/features/authentication/captcha) +- [Captcha (React Native)](/solutions/embedded-wallets/integration-guide/react-native/authentication/captcha) +- [Authentication](/solutions/embedded-wallets/integration-guide/typescript/auth) +- [Auth Proxy](/features/authentication/auth-proxy)