Skip to main content

Overview

Captcha protection 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 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) for that walkthrough.
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: 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

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:
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.

Vanilla JavaScript

Add the Turnstile script:
Then render it and hold onto the token:

React (web)

@turnkey/react-wallet-kit uses @marsidev/react-turnstile internally, so it’s the wrapper we recommend for React apps built on core:
The site key is stable, so fetch it once and cache it rather than on every auth attempt.

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:
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

Methods that accept captchaToken

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.

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.