diff --git a/.changeset/clean-dogs-protect.md b/.changeset/clean-dogs-protect.md
new file mode 100644
index 0000000000..546e9e494d
--- /dev/null
+++ b/.changeset/clean-dogs-protect.md
@@ -0,0 +1,5 @@
+---
+"better-auth": patch
+---
+
+Support Vercel BotID checks on protected authentication routes in Vercel-hosted applications.
diff --git a/.cspell/third-party.txt b/.cspell/third-party.txt
index b9a2d3a4b0..3e1d44b62d 100644
--- a/.cspell/third-party.txt
+++ b/.cspell/third-party.txt
@@ -49,3 +49,4 @@ elif
zitadel
anthropics
nextjs
+botid
diff --git a/docs/content/docs/plugins/captcha.mdx b/docs/content/docs/plugins/captcha.mdx
index 4d74ff17fb..ef2ba5f558 100644
--- a/docs/content/docs/plugins/captcha.mdx
+++ b/docs/content/docs/plugins/captcha.mdx
@@ -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)
- This plugin works out of the box with Email & Password authentication. To use it with other authentication methods, you will need to configure the endpoints array in the plugin options.
+ The default endpoints cover Email & Password authentication. Other authentication methods need an explicit endpoints array. Vercel BotID also needs the client-side setup below.
## Installation
+
+ ### Install dependencies (Optional)
+
+ If you are using **Vercel BotID**, install its SDK in your application:
+
+ ```package-install
+ npm install botid
+ ```
+
+
### Add the plugin to your **auth** config
@@ -37,13 +48,13 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by
- ### Add the captcha token to your request headers
+ ### Send the token for token-based providers
The `x-captcha-user-remote-ip` header is no longer required—IP is now auto-detected server-side.
- 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
+## 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
- 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).
it validates the captcha token on the server, by calling the captcha provider's `/siteverify`.
+
+
+ **Vercel BotID** is the exception: it uses the supplied `checkBotId` function instead of a captcha token or `/siteverify` call.
+
* 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.
## 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.
diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json
index 49c561b14d..13bcc2b5d6 100644
--- a/packages/better-auth/package.json
+++ b/packages/better-auth/package.json
@@ -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",
diff --git a/packages/better-auth/src/plugins/captcha/captcha.test.ts b/packages/better-auth/src/plugins/captcha/captcha.test.ts
index 27748997fc..a53a85ce4c 100644
--- a/packages/better-auth/src/plugins/captcha/captcha.test.ts
+++ b/packages/better-auth/src/plugins/captcha/captcha.test.ts
@@ -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(() => {}),
+ });
+ 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();
diff --git a/packages/better-auth/src/plugins/captcha/constants.ts b/packages/better-auth/src/plugins/captcha/constants.ts
index 72dfbacebc..6b04807eec 100644
--- a/packages/better-auth/src/plugins/captcha/constants.ts
+++ b/packages/better-auth/src/plugins/captcha/constants.ts
@@ -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 = {
+export const siteVerifyMap: Record<
+ Exclude,
+ string
+> = {
[Providers.CLOUDFLARE_TURNSTILE]:
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
[Providers.GOOGLE_RECAPTCHA]:
diff --git a/packages/better-auth/src/plugins/captcha/index.ts b/packages/better-auth/src/plugins/captcha/index.ts
index 28f27ad639..9f584496a3 100644
--- a/packages/better-auth/src/plugins/captcha/index.ts
+++ b/packages/better-auth/src/plugins/captcha/index.ts
@@ -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);
}
diff --git a/packages/better-auth/src/plugins/captcha/types.ts b/packages/better-auth/src/plugins/captcha/types.ts
index 60c0004dc4..83fc7d0fd5 100644
--- a/packages/better-auth/src/plugins/captcha/types.ts
+++ b/packages/better-auth/src/plugins/captcha/types.ts
@@ -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;
+
+ /**
+ * 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;
+}
+
export type CaptchaOptions =
| GoogleRecaptchaOptions
| CloudflareTurnstileOptions
| HCaptchaOptions
- | CaptchaFoxOptions;
+ | CaptchaFoxOptions
+ | VercelBotIdOptions;
diff --git a/packages/better-auth/src/plugins/captcha/utils.ts b/packages/better-auth/src/plugins/captcha/utils.ts
index 80e99f4247..ef8151f036 100644
--- a/packages/better-auth/src/plugins/captcha/utils.ts
+++ b/packages/better-auth/src/plugins/captcha/utils.ts
@@ -13,3 +13,21 @@ export const encodeToURLParams = (obj: Record): string => {
return params.toString();
};
+
+/**
+ * Rejects after `timeoutMs` without cancelling the underlying promise.
+ */
+export const withTimeout = (
+ promise: Promise,
+ timeoutMs: number,
+): Promise => {
+ let timer: ReturnType;
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(
+ () => reject(new Error("Operation timed out")),
+ timeoutMs,
+ );
+ });
+
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+};
diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts
index 3f22de64c0..60b290da11 100644
--- a/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts
+++ b/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts
@@ -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";
diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/vercel-botid.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/vercel-botid.ts
new file mode 100644
index 0000000000..be5b5cb0d0
--- /dev/null
+++ b/packages/better-auth/src/plugins/captcha/verify-handlers/vercel-botid.ts
@@ -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;
+ validateRequest?: (ctx: ValidateRequestContext) => boolean | Promise;
+};
+
+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,
+ });
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d5e5b330b7..0ab9a48f00 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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