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
Install the Better Auth plugin
Add the captcha plugin to your Better Auth server config and pick a provider:
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.
import { AuthProvider } from "@better-auth-ui/heroui"
import { captchaPlugin } from "@better-auth-ui/react/plugins"
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-turnstileimport 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" }}
/>
)
}import { AuthProvider } from "@better-auth-ui/heroui"
import { captchaPlugin } from "@better-auth-ui/react/plugins"
import { TurnstileWidget } from "@/components/turnstile-widget"
<AuthProvider
authClient={authClient}
plugins={[captchaPlugin({ render: TurnstileWidget })]}
>
{children}
</AuthProvider>hCaptcha
npm install @hcaptcha/react-hcaptchaimport 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"}
/>
)
}import { AuthProvider } from "@better-auth-ui/heroui"
import { captchaPlugin } from "@better-auth-ui/react/plugins"
import { HCaptchaWidget } from "@/components/hcaptcha-widget"
<AuthProvider
authClient={authClient}
plugins={[captchaPlugin({ render: HCaptchaWidget })]}
>
{children}
</AuthProvider>CaptchaFox
npm install @captchafox/reactimport 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"}
/>
)
}import { AuthProvider } from "@better-auth-ui/heroui"
import { captchaPlugin } from "@better-auth-ui/react/plugins"
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 thex-captcha-responseheader 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()tosetReset— Better Auth's captcha middleware consumes the token via/siteverifyeven 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 registeredreset()from itsonError, 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