BETTER-AUTH. UI
Plugins

Captcha

Add bot protection to Solid/Zaidan sign-up, sign-in, and password reset forms.

The captcha plugin adds a widget to the copied Solid/Zaidan authentication forms. It sends the resolved token in the x-captcha-response header.

The plugin supports Cloudflare Turnstile, hCaptcha, CaptchaFox, and reCAPTCHA. Provide a Solid render component that connects the provider callbacks to the plugin.

It contributes:

  • A captcha widget rendered above the submit button on sign-in, sign-up, and forgot-password forms
  • Automatic header management. The plugin clears the token after an error or expiration, or when the component unmounts.
  • Automatic widget refresh after an unsuccessful submission. Captcha tokens are single-use, so each retry requires a new token.

Social sign-in

Provider buttons forward the current CAPTCHA token to /sign-in/social. Add this endpoint to the server CAPTCHA configuration to protect social sign-in. Failed requests clear the token and reset the widget before another attempt.

Use socialSignInMode="redirect" for CAPTCHA-protected social sign-in. Better Auth 1.7's experimental popup API does not accept fetchOptions or forward CAPTCHA headers. Popup failures reset the widget, but the popup flow cannot send the token.

Setup

The copied authentication forms already contain the captcha widget slot. Configure the Better Auth server plugin and register the Solid UI plugin with your widget.

Install the Better Auth plugin

Add the captcha plugin to your Better Auth server config and choose a provider:

src/lib/auth.ts
import { betterAuth } from "better-auth"
import { captcha } from "better-auth/plugins"

export const auth = betterAuth({
  // ...
  plugins: [
    captcha({ 
      provider: "cloudflare-turnstile", // or "hcaptcha", "captchafox", "google-recaptcha"
      secretKey: process.env.TURNSTILE_SECRET_KEY as string
    }) 
  ]
})

By default, Better Auth protects /sign-up/email, /sign-in/email, and /request-password-reset.

Better Auth 1.7 and later matches complete authentication paths. Use an exact endpoint or an explicit wildcard such as /sign-in/*.

Do not use a partial prefix such as /sign-in. To protect username and social sign-in, add their endpoints explicitly:

src/lib/auth.ts
captcha({
  provider: "cloudflare-turnstile",
  secretKey: process.env.TURNSTILE_SECRET_KEY as string,
  endpoints: [ 
    "/sign-up/email", 
    "/sign-in/email", 
    "/sign-in/username", 
    "/sign-in/social", 
    "/request-password-reset"
  ] 
})

Register the Solid UI plugin

Pass captchaPlugin({ render }) to <AuthProvider>. render is a Solid component that receives setToken, clearToken, and setReset and is responsible for mounting your provider widget.

src/components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/solid/plugins/captcha"
import { AuthProvider } from "@/components/auth/auth-provider"
import { authClient } from "@/lib/auth-client"

import { CaptchaWidget } from "@/components/captcha-widget"

<AuthProvider
  authClient={authClient}
  plugins={[
    captchaPlugin({ render: CaptchaWidget }) 
  ]}
>
  {children}
</AuthProvider>

The render component is mounted as a real Solid component, so Solid primitives and context work inside it. See the Providers section below for ready-to-use widget patterns.

Providers

The examples use @better-captcha/solidjs. This library supports the providers that Better Auth can verify.

You can use another Solid widget. It must call setToken after success, call clearToken after an error, and register reset with setReset.

The server-side Better Auth provider names are the same as shadcn. Only the client-side widget package changes from React to Solid:

ProviderBetter Auth providershadcn React widgetSolid/Zaidan widget
Cloudflare Turnstile"cloudflare-turnstile"@marsidev/react-turnstile@better-captcha/solidjs/provider/turnstile
hCaptcha"hcaptcha"@hcaptcha/react-hcaptcha@better-captcha/solidjs/provider/hcaptcha
CaptchaFox"captchafox"@captchafox/react@better-captcha/solidjs/provider/captcha-fox

Cloudflare Turnstile

npm install @better-captcha/solidjs
src/components/turnstile-widget.tsx
import type { CaptchaRenderProps } from "@better-auth-ui/solid/plugins/captcha"
import {
  createCaptchaController,
  Turnstile,
  type TurnstileHandle
} from "@better-captcha/solidjs/provider/turnstile"
import { onCleanup, onMount } from "solid-js"

export function TurnstileWidget({
  setToken,
  clearToken,
  setReset
}: CaptchaRenderProps) {
  const controller = createCaptchaController<TurnstileHandle>()

  onMount(() => {
    setReset(() => controller.handle()?.reset())
  })

  onCleanup(() => setReset(null))

  return (
    <Turnstile
      sitekey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
      controller={controller}
      onSolve={setToken}
      onError={clearToken}
      options={{ size: "flexible" }}
    />
  )
}
src/components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/solid/plugins/captcha"
import { AuthProvider } from "@/components/auth/auth-provider"

import { TurnstileWidget } from "@/components/turnstile-widget"

<AuthProvider
  authClient={authClient}
  plugins={[captchaPlugin({ render: TurnstileWidget })]}
>
  {children}
</AuthProvider>

hCaptcha

npm install @better-captcha/solidjs
src/components/hcaptcha-widget.tsx
import type { CaptchaRenderProps } from "@better-auth-ui/solid/plugins/captcha"
import {
  createCaptchaController,
  HCaptcha,
  type HCaptchaHandle
} from "@better-captcha/solidjs/provider/hcaptcha"
import { onCleanup, onMount } from "solid-js"

export function HCaptchaWidget({
  setToken,
  clearToken,
  setReset
}: CaptchaRenderProps) {
  const controller = createCaptchaController<HCaptchaHandle>()

  onMount(() => {
    setReset(() => controller.handle()?.reset())
  })

  onCleanup(() => setReset(null))

  return (
    <HCaptcha
      sitekey={import.meta.env.VITE_HCAPTCHA_SITE_KEY}
      controller={controller}
      onSolve={setToken}
      onError={clearToken}
      options={{ theme: "auto" }}
    />
  )
}
src/components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/solid/plugins/captcha"
import { AuthProvider } from "@/components/auth/auth-provider"

import { HCaptchaWidget } from "@/components/hcaptcha-widget"

<AuthProvider
  authClient={authClient}
  plugins={[captchaPlugin({ render: HCaptchaWidget })]}
>
  {children}
</AuthProvider>

CaptchaFox

npm install @better-captcha/solidjs
src/components/captchafox-widget.tsx
import type { CaptchaRenderProps } from "@better-auth-ui/solid/plugins/captcha"
import {
  CaptchaFox,
  type CaptchaFoxHandle,
  createCaptchaController
} from "@better-captcha/solidjs/provider/captcha-fox"
import { onCleanup, onMount } from "solid-js"

export function CaptchaFoxWidget({
  setToken,
  clearToken,
  setReset
}: CaptchaRenderProps) {
  const controller = createCaptchaController<CaptchaFoxHandle>()

  onMount(() => {
    setReset(() => controller.handle()?.reset())
  })

  onCleanup(() => setReset(null))

  return (
    <CaptchaFox
      sitekey={import.meta.env.VITE_CAPTCHAFOX_SITE_KEY}
      controller={controller}
      onSolve={setToken}
      onError={clearToken}
      options={{ theme: "auto" }}
    />
  )
}
src/components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/solid/plugins/captcha"
import { AuthProvider } from "@/components/auth/auth-provider"

import { CaptchaFoxWidget } from "@/components/captchafox-widget"

<AuthProvider
  authClient={authClient}
  plugins={[captchaPlugin({ render: CaptchaFoxWidget })]}
>
  {children}
</AuthProvider>

Options

Prop

Type

Render props

The render component receives:

Prop

Type

  • Connect the provider's success callback to setToken. It adds the x-captcha-response header to the next Better Auth request.

  • Connect the error and expiration callbacks to clearToken. It removes the header before the application sends a stale token.

  • Connect the widget's reset() function to setReset. Better Auth consumes the token through /siteverify before the authentication handler completes.

    A rejected request still consumes the token. Each protected form calls the registered reset() function from onError and clears the old token.

The plugin also clears the header when the component unmounts. The application does not need additional cleanup.

Last updated on

On this page