# Captcha Plugin

> Protect authentication endpoints from bots with Cloudflare Turnstile captcha.

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/better-auth/captcha-plugin*

---

Block automated attacks and bot registrations with captcha verification. The kit uses Cloudflare Turnstile, a privacy-focused, user-friendly alternative to reCAPTCHA.

This page is part of the [Authentication documentation](./overview).

## Overview

The captcha plugin adds bot protection to authentication flows. When enabled, users must complete a Turnstile challenge before signing in or registering. Turnstile is designed to minimize user friction while effectively blocking bots.

**Why Cloudflare Turnstile?**
- Privacy-focused (no tracking cookies)
- Often invisible to users (challenges only when suspicious)
- Free tier available
- No "select all traffic lights" puzzles

## Setup

### 1. Create Turnstile Widget

1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile)
2. Click **Add Site**
3. Enter your domain (use `localhost` for development)
4. Select widget mode:
   - **Managed** (recommended) - Cloudflare decides when to show challenges
   - **Non-interactive** - Invisible verification
   - **Invisible** - Completely hidden verification
5. Copy the **Site Key** and **Secret Key**

### 2. Configure Environment Variables

```bash {% title="apps/web/.env" %}
TURNSTILE_SECRET_KEY=your_secret_key
VITE_CAPTCHA_SITE_KEY=your_site_key
VITE_CAPTCHA_WIDGET_SIZE=invisible
```

| Variable | Visibility | Description |
|----------|------------|-------------|
| `TURNSTILE_SECRET_KEY` | Server only | Secret key for server-side verification |
| `VITE_CAPTCHA_SITE_KEY` | Public | Site key for client-side widget |
| `VITE_CAPTCHA_WIDGET_SIZE` | Public | Widget size: `invisible` (default), `normal`, or `compact`. Use `normal`/`compact` with a **Managed** site key. |

### 3. Automatic Enablement

The plugin automatically enables when `TURNSTILE_SECRET_KEY` is set. No code changes required.

## Implementation

The captcha plugin is configured in `packages/better-auth/src/plugins/captcha.ts`:

```typescript
import * as z from 'zod';

const turnstileSecretKey = z
  .string()
  .min(1)
  .optional()
  .parse(process.env.TURNSTILE_SECRET_KEY);

export async function createCaptchaPlugin() {
  if (!turnstileSecretKey) {
    return [] as never;
  }

  const { captcha } = await import('better-auth/plugins');

  return [
    captcha({
      provider: 'cloudflare-turnstile',
      secretKey: turnstileSecretKey,
    }),
  ];
}
```

**Key behaviors:**
- Returns empty array when no secret key is configured
- Uses dynamic import to avoid loading plugin when disabled
- Validates secret key with Zod

## Protected Endpoints

When enabled, captcha protection applies to:

- Sign-in (`/api/auth/sign-in`)
- Sign-up (`/api/auth/sign-up`)
- Password reset (`/api/auth/forgot-password`)

## Development Mode

For local development, you have two options:

### Option 1: Use Turnstile Test Keys

Cloudflare provides test keys that always pass:

```bash {% title="apps/web/.env" %}
# Test keys that always pass
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
VITE_CAPTCHA_SITE_KEY=1x00000000000000000000AA
```

### Option 2: Disable Captcha

Simply don't set `TURNSTILE_SECRET_KEY` in development. The plugin gracefully disables.

## Widget Modes

Turnstile offers three widget modes:

| Mode | User Experience | Use Case |
|------|-----------------|----------|
| **Managed** | Shows challenge only when needed | Recommended for most apps |
| **Non-interactive** | Spinner, no user action | When you want visible verification |
| **Invisible** | Completely hidden | Maximum UX, slightly less protection |

Configure the mode in Cloudflare Dashboard when creating the widget.

## Client Integration

The sign-in and sign-up forms automatically render the Turnstile widget when `VITE_CAPTCHA_SITE_KEY` is set. The widget token is included in auth requests.

```typescript
// The auth client automatically handles captcha tokens
// No manual integration needed for standard auth forms
await authClient.signIn.email({
  email: 'user@example.com',
  password: 'password',
  // captcha token is automatically included
});
```

## Troubleshooting

### Captcha Always Fails

1. Verify secret key matches site key (they're a pair)
2. Check domain configuration in Cloudflare matches your app URL
3. Ensure you're not using production keys on localhost

### Widget Not Showing

1. Verify `VITE_CAPTCHA_SITE_KEY` is set
2. Check browser console for errors
3. Ensure the Turnstile script is loading

### High False Positive Rate

1. Switch from Invisible to Managed mode
2. Check if your users are using VPNs or Tor
3. Review Turnstile analytics in Cloudflare Dashboard

## Common Pitfalls

- **Mismatched keys**: Site key and secret key must be from the same Turnstile widget.
- **Wrong domain**: Development widgets must include `localhost` in allowed domains.
- **Missing public key**: Server-side works but widget won't render without `VITE_CAPTCHA_SITE_KEY`.
- **Test keys in production**: Always use real keys in production environments.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Is Cloudflare Turnstile free?", "answer": "Yes. Turnstile has a generous free tier. Paid tiers offer additional features like custom branding and higher limits."},
     {"question": "Can I use reCAPTCHA instead?", "answer": "Better Auth primarily supports Turnstile. Using reCAPTCHA would require custom implementation or a different plugin."},
     {"question": "Does captcha work with OAuth sign-in?", "answer": "OAuth authentication bypasses the captcha since users are authenticated through the OAuth provider's own security measures."},
     {"question": "How do I test captcha locally?", "answer": "Use Cloudflare's test keys that always pass, or simply don't configure the secret key to disable captcha in development."},
     {"question": "Will captcha slow down sign-in?", "answer": "Minimally. Managed mode only shows challenges for suspicious traffic. Most legitimate users see no visible captcha."}
   ]
/%}

---

**Next:** [OTP Plugin →](./otp-plugin)
