BETTER-AUTH. UI
Plugins

Captcha

Add bot protection to sign-up, sign-in, and password reset using a provider-agnostic captcha widget.

The captcha plugin renders a captcha widget on the sign-in, sign-up, and forgot-password forms and forwards the resolved token to Better Auth as the x-captcha-response header. It's provider-agnostic — you bring your own captcha library (Cloudflare Turnstile, hCaptcha, CaptchaFox, reCAPTCHA) and pass a render component that wires the provider's callbacks to setToken, clearToken, and setReset.

It contributes:

  • A captcha widget rendered above the submit button on sign-in, sign-up, and forgot-password forms
  • Automatic header management — the resolved token is attached to the next Better Auth request and cleared on error, expiry, or unmount
  • Automatic widget refresh after a failed submission — captcha tokens are single-use, so retries (wrong password, email already taken, etc.) need a fresh token

Setup

The captcha plugin requires no additional UI installation — the widget slot is built into the <SignIn>, <SignUp>, and <ForgotPassword> forms. You only need to configure the Better Auth server plugin and register the client plugin with your captcha widget.

Install the Better Auth plugin

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

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
    }) 
  ]
})

The Better Auth captcha plugin protects /sign-up/email, /sign-in/email, and /request-password-reset by default — exactly the views the UI plugin renders the widget on. To also protect /sign-in/username, pass it explicitly:

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

Register the UI plugin

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

components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/react/plugins"
import { AuthProvider } from "@/components/auth/auth-provider"

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

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

The render component is mounted as a real React component, so hooks like useTheme work inside it. See the Providers section below for ready-to-use widgets.

Providers

Cloudflare Turnstile

npm install @marsidev/react-turnstile
components/turnstile-widget.tsx
import type { CaptchaRenderProps } from "@better-auth-ui/react/plugins"
import { type TurnstileInstance, Turnstile } from "@marsidev/react-turnstile"
import { useEffect, useRef } from "react"

export function TurnstileWidget({
  setToken,
  clearToken,
  setReset
}: CaptchaRenderProps) {
  const ref = useRef<TurnstileInstance>(null)

  useEffect(() => {
    setReset(() => ref.current?.reset())
    return () => setReset(null)
  }, [setReset])

  return (
    <Turnstile
      ref={ref}
      siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
      onSuccess={setToken}
      onError={clearToken}
      onExpire={clearToken}
      options={{ size: "flexible" }}
    />
  )
}
components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/react/plugins"
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 @hcaptcha/react-hcaptcha
components/hcaptcha-widget.tsx
import type { CaptchaRenderProps } from "@better-auth-ui/react/plugins"
import HCaptcha from "@hcaptcha/react-hcaptcha"
import { useTheme } from "next-themes"
import { useEffect, useRef } from "react"

export function HCaptchaWidget({
  setToken,
  clearToken,
  setReset
}: CaptchaRenderProps) {
  const { resolvedTheme } = useTheme()
  const ref = useRef<HCaptcha>(null)

  useEffect(() => {
    setReset(() => ref.current?.resetCaptcha())
    return () => setReset(null)
  }, [setReset])

  return (
    <HCaptcha
      ref={ref}
      sitekey={import.meta.env.VITE_HCAPTCHA_SITE_KEY}
      onVerify={setToken}
      onExpire={clearToken}
      onError={clearToken}
      theme={resolvedTheme === "dark" ? "dark" : "light"}
    />
  )
}
components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/react/plugins"
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 @captchafox/react
components/captchafox-widget.tsx
import type { CaptchaRenderProps } from "@better-auth-ui/react/plugins"
import { CaptchaFox, type CaptchaFoxInstance } from "@captchafox/react"
import { useTheme } from "next-themes"
import { useEffect, useRef } from "react"

export function CaptchaFoxWidget({
  setToken,
  clearToken,
  setReset
}: CaptchaRenderProps) {
  const { resolvedTheme } = useTheme()
  const ref = useRef<CaptchaFoxInstance>(null)

  useEffect(() => {
    setReset(() => ref.current?.reset())
    return () => setReset(null)
  }, [setReset])

  return (
    <CaptchaFox
      ref={ref}
      key={resolvedTheme}
      sitekey={import.meta.env.VITE_CAPTCHAFOX_SITE_KEY}
      onVerify={setToken}
      onExpire={clearToken}
      onError={clearToken}
      theme={resolvedTheme === "dark" ? "dark" : "light"}
    />
  )
}
components/providers.tsx
import { captchaPlugin } from "@better-auth-ui/react/plugins"
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

  • Wire your provider's success callback to setToken — it sets the x-captcha-response header on the next Better Auth request.
  • Wire error / expire callbacks to clearToken — it removes the header so a stale token isn't sent.
  • Wire your widget's reset() to setReset — Better Auth's captcha middleware consumes the token via /siteverify even when the auth handler later rejects the request (wrong password, email already taken, etc.), so retries with the same token would be rejected. Each captcha-protected form calls the registered reset() from its onError, and the consumed token is cleared automatically.

The header is also cleared automatically on unmount, so you don't need to handle that manually.

Last updated on

On this page