-
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
base: main
Are you sure you want to change the base?
Changes from all commits
ddd60df
ef1ba03
3d224db
fd3ac87
d6da3c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| <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) | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment.
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-kitusersThere was a problem hiding this comment.
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