feat(captcha): add Vercel BotID provider (#11016)

Co-authored-by: Taesu <bytaesu@gmail.com>
Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com>
This commit is contained in:
David Brito
2026-09-22 20:11:43 +00:00
committed by GitHub
co-authored by Taesu Taesu
parent af883851ae
commit 3d0efa308d
12 changed files with 417 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Support Vercel BotID checks on protected authentication routes in Vercel-hosted applications.
+1
View File
@@ -49,3 +49,4 @@ elif
zitadel
anthropics
nextjs
botid
+76 -6
View File
@@ -10,14 +10,25 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
* [Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/)
* [hCaptcha](https://www.hcaptcha.com/)
* [CaptchaFox](https://captchafox.com/)
* [Vercel BotID](https://vercel.com/docs/botid)
<Callout type="info">
This plugin works out of the box with <Link href="/docs/authentication/email-password">Email & Password</Link> authentication. To use it with other authentication methods, you will need to configure the <Link href="/docs/plugins/captcha#plugin-options">endpoints</Link> array in the plugin options.
The default endpoints cover <Link href="/docs/authentication/email-password">Email & Password</Link> authentication. Other authentication methods need an explicit <Link href="/docs/plugins/captcha#plugin-options">endpoints</Link> array. Vercel BotID also needs the client-side setup below.
</Callout>
## Installation
<Steps>
<Step>
### Install dependencies (Optional)
If you are using **Vercel BotID**, install its SDK in your application:
```package-install
npm install botid
```
</Step>
<Step>
### Add the plugin to your **auth** config
@@ -37,13 +48,13 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
</Step>
<Step>
### Add the captcha token to your request headers
### Send the token for token-based providers
<Callout type="warning">
The `x-captcha-user-remote-ip` header is no longer required—IP is now auto-detected server-side.
</Callout>
Add the captcha token to your request headers for all protected endpoints. This example shows how to include it in a `signIn` request:
For token-based providers, add the captcha token to requests for protected endpoints. This example shows a `signIn` request:
```ts
import { authClient } from "@/lib/auth-client"
@@ -66,30 +77,89 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
</Step>
</Steps>
## Providers
### Vercel BotID
BotID requires a Vercel deployment and client-side setup. Follow [Vercel's setup guide](https://vercel.com/docs/botid/get-started) for the rewrites and client integration appropriate to your framework. The Better Auth plugin does not use a `secretKey` or an `x-captcha-response` header:
```ts title="auth.ts"
import { checkBotId } from "botid/server";
import { betterAuth } from "better-auth";
import { captcha } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
captcha({
provider: "vercel-botid",
checkBotId,
}),
],
});
```
When using `initBotId`, protect Better Auth's default auth routes at their actual request paths:
```ts
import { initBotId } from "botid/client/core";
initBotId({
protect: [
{ path: "/api/auth/sign-up/email", method: "POST" },
{ path: "/api/auth/sign-in/email", method: "POST" },
{ path: "/api/auth/request-password-reset", method: "POST" },
],
});
```
BotID uses the browser's full request path. The plugin's `endpoints` option omits the Better Auth base path, as in `/sign-in/email`. Keep both in sync if you change `basePath` or `endpoints`.
With BotID initialized in the browser, sign in normally without a captcha token header:
```ts
import { authClient } from "@/lib/auth-client";
const { data, error } = await authClient.signIn.email({
email: "user@example.com",
password: "secure-password",
});
```
BotID does not support [native HTML form submissions](https://vercel.com/docs/botid/form-submissions).
Local development [allows requests by default](https://vercel.com/docs/botid/local-development-behavior), so verify the full flow on Vercel before relying on it in production. For route-specific detection levels, follow [Vercel's advanced configuration](https://vercel.com/docs/botid/advanced-configuration).
## How it works
<Steps>
<Step>
The plugin acts as a middleware: it intercepts all `POST` requests to configured endpoints (see `endpoints`
The plugin acts as a middleware: it intercepts requests to configured endpoints (see `endpoints`
in the [Plugin Options](#plugin-options) section).
</Step>
<Step>
it validates the captcha token on the server, by calling the captcha provider's `/siteverify`.
<Callout type="info">
**Vercel BotID** is the exception: it uses the supplied `checkBotId` function instead of a captcha token or `/siteverify` call.
</Callout>
</Step>
<Step>
* if the token is missing, gets rejected by the captcha provider, or if the `/siteverify` endpoint is
unavailable, the plugin returns an error and interrupts the request.
* if the token is accepted by the captcha provider, the middleware returns `undefined`, meaning the request is allowed to proceed.
* for **Vercel BotID**, the request is rejected when `checkBotId` (or your custom `validateRequest`) determines it's a bot, and allowed to proceed otherwise.
</Step>
</Steps>
## Plugin Options
* **`provider` (required)**: your captcha provider.
* **`secretKey` (required)**: your provider's secret key used for the server-side validation.
* **`secretKey` (required for all providers except `vercel-botid`)**: your provider's secret key used for the server-side validation.
* `endpoints` (optional): replaces the default array of paths where captcha verification is enforced. If set, only the specified paths will be protected. Paths match exactly unless they include wildcards, such as `/sign-in/*` for one segment or `/sign-in/**` for nested routes. Default is `["/sign-up/email", "/sign-in/email", "/request-password-reset"]`.
* `minScore` (optional - only *Google ReCAPTCHA v3*): minimum score threshold. Default is `0.5`.
* `siteKey` (optional - only *hCaptcha* and *CaptchaFox*): prevents tokens issued on one sitekey from being redeemed elsewhere.
* `siteVerifyURLOverride` (optional): overrides endpoint URL for the captcha verification request.
* `siteVerifyURLOverride` (optional - token-based providers only): overrides the captcha verification URL.
* `checkBotId` (required for *Vercel BotID*): Vercel's server-side check function, imported from `botid/server` in your application. Wrap it in a function if you need to pass SDK options.
* `validateRequest` (optional - only *Vercel BotID*): custom validation logic. Return `true` to allow the request, `false` to reject it. By default, only results with `isBot: false` are allowed.
+1
View File
@@ -504,6 +504,7 @@
"@types/pg": "^8.20.0",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"botid": "^1.5.10",
"happy-dom": "^20.8.9",
"msw": "^2.14.6",
"next": "^16.3.4",
@@ -1,4 +1,5 @@
import * as betterFetchModule from "@better-fetch/fetch";
import { checkBotId as vercelCheckBotId } from "botid/server";
import { describe, expect, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { emailOTP } from "../email-otp";
@@ -866,6 +867,198 @@ describe("captcha", async () => {
});
});
describe("vercel-botid", () => {
it.each([
"/sign-up/email",
"/sign-in/email",
"/request-password-reset",
])("rejects bots on %s without a captcha token or secret key", async (path) => {
const checkBotId = vi.fn(async () => ({ isBot: true }));
const { auth } = await getTestInstance({
plugins: [captcha({ provider: "vercel-botid", checkBotId })],
});
const res = await auth.handler(
new Request(`http://localhost:3000/api/auth${path}`, {
method: "POST",
}),
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({
code: "VERIFICATION_FAILED",
});
expect(checkBotId).toHaveBeenCalledOnce();
});
/**
* @see https://vercel.com/docs/botid/local-development-behavior
*/
it("rejects the SDK's development BAD-BOT verdict", async () => {
const checkBotId = () =>
vercelCheckBotId({ developmentOptions: { bypass: "BAD-BOT" } });
const { auth } = await getTestInstance({
plugins: [captcha({ provider: "vercel-botid", checkBotId })],
});
const res = await auth.handler(
new Request("http://localhost:3000/api/auth/sign-in/email", {
method: "POST",
}),
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({
code: "VERIFICATION_FAILED",
});
});
/**
* @see https://vercel.com/docs/botid/local-development-behavior
*/
it("allows the SDK's development HUMAN verdict without a captcha token", async () => {
const checkBotId = vi.fn(() =>
vercelCheckBotId({ developmentOptions: { bypass: "HUMAN" } }),
);
const { client, testUser } = await getTestInstance({
plugins: [captcha({ provider: "vercel-botid", checkBotId })],
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(res.error).toBeNull();
expect(res.data?.user).toBeDefined();
expect(checkBotId).toHaveBeenCalledOnce();
});
it("allows a verified bot when custom validation opts in", async () => {
const checkBotId = vi.fn(async () => ({
isBot: true,
isVerifiedBot: true,
verifiedBotName: "trusted-agent",
}));
const { client, testUser } = await getTestInstance({
plugins: [
captcha({
provider: "vercel-botid",
checkBotId,
validateRequest: ({ verification }) =>
verification.isVerifiedBot === true &&
verification.verifiedBotName === "trusted-agent",
}),
],
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(res.error).toBeNull();
expect(res.data?.user).toBeDefined();
});
it("rejects a human when custom validation rejects the request", async () => {
const checkBotId = vi.fn(async () => ({ isBot: false }));
const { client } = await getTestInstance({
plugins: [
captcha({
provider: "vercel-botid",
checkBotId,
validateRequest: () => false,
}),
],
});
const res = await client.signIn.email({
email: "test@test.com",
password: "test123456",
});
expect(res.error?.status).toBe(403);
});
it("returns a generic 500 when the check fails", async () => {
const checkBotId = vi.fn(async () => {
throw new Error("private BotID failure");
});
const { client } = await getTestInstance({
plugins: [captcha({ provider: "vercel-botid", checkBotId })],
});
const res = await client.signIn.email({
email: "test@test.com",
password: "test123456",
});
expect(res.error?.status).toBe(500);
expect(res.error?.code).toBe("UNKNOWN_ERROR");
expect(res.error?.message).toBe("Something went wrong");
});
it("does not check routes outside the protected endpoints", async () => {
const checkBotId = vi.fn(async () => ({ isBot: true }));
const { auth } = await getTestInstance({
plugins: [captcha({ provider: "vercel-botid", checkBotId })],
});
const res = await auth.handler(
new Request("http://localhost:3000/api/auth/ok"),
);
expect(res.status).toBe(200);
expect(checkBotId).not.toHaveBeenCalled();
});
it("fails closed when the check never resolves", async () => {
vi.useFakeTimers();
try {
const checkBotId = () => new Promise<{ isBot: boolean }>(() => {});
const { vercelBotId } = await import("./verify-handlers/vercel-botid");
const resultPromise = vercelBotId({
request: new Request("http://localhost/sign-in/email"),
checkBotId,
});
const settled = vi.fn();
void resultPromise.then(settled, settled);
const assertion = expect(resultPromise).rejects.toThrow(
"CAPTCHA service unavailable",
);
await vi.advanceTimersByTimeAsync(CAPTCHA_VERIFY_TIMEOUT_MS - 1);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await assertion;
expect(settled).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("fails closed when custom validation never resolves", async () => {
vi.useFakeTimers();
try {
const { vercelBotId } = await import("./verify-handlers/vercel-botid");
const resultPromise = vercelBotId({
request: new Request("http://localhost/sign-in/email"),
checkBotId: async () => ({ isBot: false }),
validateRequest: () => new Promise<boolean>(() => {}),
});
const settled = vi.fn();
void resultPromise.then(settled, settled);
const assertion = expect(resultPromise).rejects.toThrow(
"CAPTCHA service unavailable",
);
await vi.advanceTimersByTimeAsync(CAPTCHA_VERIFY_TIMEOUT_MS - 1);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await assertion;
expect(settled).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
});
describe("action and hostname binding", async () => {
it("rejects a Turnstile token whose action does not match expectedAction", async () => {
const log = vi.fn();
@@ -19,9 +19,13 @@ export const Providers = {
GOOGLE_RECAPTCHA: "google-recaptcha",
HCAPTCHA: "hcaptcha",
CAPTCHAFOX: "captchafox",
VERCEL_BOTID: "vercel-botid",
} as const;
export const siteVerifyMap: Record<Provider, string> = {
export const siteVerifyMap: Record<
Exclude<Provider, typeof Providers.VERCEL_BOTID>,
string
> = {
[Providers.CLOUDFLARE_TURNSTILE]:
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
[Providers.GOOGLE_RECAPTCHA]:
@@ -58,6 +58,14 @@ export const captcha = (options: CaptchaOptions) =>
return undefined;
}
if (options.provider === Providers.VERCEL_BOTID) {
return await verifyHandlers.vercelBotId({
request,
checkBotId: options.checkBotId,
validateRequest: options.validateRequest,
});
}
if (!options.secretKey) {
throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY.message);
}
@@ -52,8 +52,55 @@ export interface CaptchaFoxOptions extends BaseCaptchaOptions {
siteKey?: string | undefined;
}
/**
* BotID verdict fields available to custom request validation.
*
* @see https://vercel.com/docs/botid/verified-bots
*/
export type BotIdVerification = {
isBot: boolean;
isVerifiedBot?: boolean | undefined;
verifiedBotName?: string | undefined;
verifiedBotCategory?: string | undefined;
};
export type ValidateRequestContext = {
request: Request;
verification: BotIdVerification;
};
/**
* Protect auth requests with Vercel BotID. The browser must also protect the
* same request paths and methods with BotID's client SDK.
*
* @see https://vercel.com/docs/botid/get-started
*/
export interface VercelBotIdOptions {
provider: typeof Providers.VERCEL_BOTID;
/**
* Auth paths to verify, without the Better Auth base path.
*
* @default ["/sign-up/email", "/sign-in/email", "/request-password-reset"]
*/
endpoints?: string[] | undefined;
/**
* Vercel's server-side check for the current request. Pass `checkBotId` from
* `botid/server`, or wrap it to supply SDK options.
*/
checkBotId: () => Promise<BotIdVerification>;
/**
* Override the default `isBot === false` decision. Return `true` to allow
* the request, including a verified bot you trust.
*
* @see https://vercel.com/docs/botid/verified-bots
*/
validateRequest?: (ctx: ValidateRequestContext) => boolean | Promise<boolean>;
}
export type CaptchaOptions =
| GoogleRecaptchaOptions
| CloudflareTurnstileOptions
| HCaptchaOptions
| CaptchaFoxOptions;
| CaptchaFoxOptions
| VercelBotIdOptions;
@@ -13,3 +13,21 @@ export const encodeToURLParams = (obj: Record<string, any>): string => {
return params.toString();
};
/**
* Rejects after `timeoutMs` without cancelling the underlying promise.
*/
export const withTimeout = <T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> => {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error("Operation timed out")),
timeoutMs,
);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
};
@@ -2,3 +2,4 @@ export { captchaFox } from "./captchafox";
export { cloudflareTurnstile } from "./cloudflare-turnstile";
export { googleRecaptcha } from "./google-recaptcha";
export { hCaptcha } from "./h-captcha";
export { vercelBotId } from "./vercel-botid";
@@ -0,0 +1,42 @@
import { middlewareResponse } from "../../../utils/middleware-response";
import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants";
import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes";
import type { BotIdVerification, ValidateRequestContext } from "../types";
import { withTimeout } from "../utils";
type Params = {
request: Request;
checkBotId: () => Promise<BotIdVerification>;
validateRequest?: (ctx: ValidateRequestContext) => boolean | Promise<boolean>;
};
export const vercelBotId = async ({
request,
checkBotId,
validateRequest,
}: Params) => {
let isValid: boolean;
try {
isValid = await withTimeout(
(async () => {
const verification = await checkBotId();
return validateRequest
? validateRequest({ request, verification })
: verification.isBot === false;
})(),
CAPTCHA_VERIFY_TIMEOUT_MS,
);
} catch (error) {
throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message, {
cause: error,
});
}
if (isValid) return undefined;
return middlewareResponse({
message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message,
code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code,
status: 403,
});
};
+19
View File
@@ -1109,6 +1109,9 @@ importers:
'@types/react-dom':
specifier: catalog:react19
version: 19.2.3(@types/react@19.2.17)
botid:
specifier: ^1.5.10
version: 1.5.11(next@16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
happy-dom:
specifier: '>=20.8.9 <21'
version: 20.8.9
@@ -8386,6 +8389,17 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
botid@1.5.11:
resolution: {integrity: sha512-KOO1A3+/vFVJk5aFoG3sNwiogKFPVR+x4aw4gQ1b2e0XoE+i5xp48/EZn+WqR07jRHeDGwHWQUOtV5WVm7xiww==}
peerDependencies:
next: '*'
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
next:
optional: true
react:
optional: true
boxen@8.0.1:
resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==}
engines: {node: '>=18'}
@@ -24562,6 +24576,11 @@ snapshots:
boolbase@1.0.0: {}
botid@1.5.11(next@16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7):
optionalDependencies:
next: 16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
boxen@8.0.1:
dependencies:
ansi-align: 3.0.1