Captcha
Add bot protection to sign-up, sign-in, and password reset using a provider-agnostic captcha widget.
The captcha plugin adds a widget to the sign-in, sign-up, and forgot-password forms. It sends the resolved token in the x-captcha-response header.
The plugin supports Cloudflare Turnstile, hCaptcha, CaptchaFox, and reCAPTCHA. Provide a 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 authentication forms already contain the captcha widget slot. Configure the Better Auth server plugin and register the client plugin with your widget.
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
})
]
})By default, the Better Auth captcha plugin protects /sign-up/email, /sign-in/email, and /request-password-reset. The UI plugin shows the widget on these views.
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:
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 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 { captchaPlugin } from "@better-auth-ui/react/plugins/captcha"
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-turnstileimport type { CaptchaRenderProps } from "@better-auth-ui/react/plugins/captcha"
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 { captchaPlugin } from "@better-auth-ui/react/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 @hcaptcha/react-hcaptchaimport type { CaptchaRenderProps } from "@better-auth-ui/react/plugins/captcha"
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 { captchaPlugin } from "@better-auth-ui/react/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 @captchafox/reactimport type { CaptchaRenderProps } from "@better-auth-ui/react/plugins/captcha"
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 { captchaPlugin } from "@better-auth-ui/react/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 thex-captcha-responseheader 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 tosetReset. Better Auth consumes the token through/siteverifybefore the authentication handler completes.A rejected request still consumes the token. Each protected form calls the registered
reset()function fromonErrorand clears the old token.
The plugin also clears the header when the component unmounts. The application does not need additional cleanup.
Last updated on