-
Notifications
You must be signed in to change notification settings - Fork 20
Add CAPTCHA feature docs #759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| --- | ||
| 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. | ||
|
|
||
| <Warning> | ||
| **Automatic enforcement applies only to `@turnkey/react-wallet-kit` users.** The Turnstile widget is rendered and captcha tokens are attached for you — no code changes required. | ||
|
|
||
| If you build your auth UI directly on `@turnkey/core` (plain JavaScript, TypeScript, Vue, Svelte, Angular, or a custom React UI), **you must integrate captcha yourself** — render the Turnstile widget, obtain a token, and pass it to the relevant SDK methods. See the [integration guides](#integration-guides) below for your setup. | ||
| </Warning> | ||
|
|
||
| <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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
|
|
||
| ## 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| ## 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> | ||
|
|
||
| ## 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. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.