How I Use Vercel BotID to Stop Bots on Auth Endpoints

BotID verifies browser challenges before proxying to the backend. Here's how I set it up in Next.js 16.

Trevor I. Lasn Trevor I. Lasn
· 5 min read
Building 0xinsider.com — the Bloomberg terminal for prediction markets.

Bots hit auth endpoints constantly. Login forms, magic links, signup codes — anything that talks to a database or sends an email is a target. Rate limiting helps, but sophisticated bots rotate IPs and fingerprints. You need something that runs a challenge in the browser before the request ever reaches your server.

Vercel BotID does this. It runs a client-side challenge on routes you specify, attaches proof-of-humanity headers to the request, and gives you a server-side check that classifies the session as human or bot. If it’s a bot, you reject the request before doing any expensive work.

I added it to 0xInsider to protect the auth flow — magic link sends, token verification, signup codes. Here’s how it works.

The architecture

My setup is a Next.js 16 frontend on Vercel that proxies API calls to a Rust backend on Railway. The proxy lives in proxy.ts middleware — it rewrites /api/auth/* requests to the backend with an API key header.

The problem: BotID’s checkBotId() function only works inside Next.js server context (route handlers or server actions). It can’t run in middleware. So I can’t just drop it into the existing proxy.

The solution: thin Next.js route handlers that sit between the client and the backend proxy.

Browser
│ fetch("/api/botid/magic-link")
│ (BotID challenge headers attached automatically)
Next.js route handler
│ checkBotId() → is this a bot?
├── YES → 403 Blocked
└── NO → proxy to Railway backend
POST /api/auth/magic-link

The client-side BotID script intercepts fetch requests to protected routes and attaches challenge headers. The server-side checkBotId() reads those headers and classifies the session. If it passes, the route handler manually proxies to the backend — same as what proxy.ts does, but with the bot check first.

Install the package and wrap your Next.js config:

Terminal window
npm i botid

next.config.ts
import { withBotId } from "botid/next/config";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// your existing config
};
export default withBotId(nextConfig);

withBotId adds proxy rewrites that serve BotID’s challenge script from your own domain. This matters because ad-blockers can’t fingerprint it as a third-party bot-detection script.

Next, register the routes you want to protect. Next.js 16 supports instrumentation-client.ts for client-side initialization:

instrumentation-client.ts
import { initBotId } from "botid/client/core";
initBotId({
protect: [
{ path: "/api/botid/magic-link", method: "POST" },
{ path: "/api/botid/verify", method: "POST" },
{ path: "/api/botid/validate-code", method: "POST" },
{ path: "/api/botid/redeem-code", method: "POST" },
],
});

When the browser makes a POST to any of these paths, BotID’s client script intercepts it, solves a challenge, and attaches the result as headers. This happens transparently — no UI, no CAPTCHA.

On the server side, I wrote a shared helper that every route handler uses:

app/api/botid/_proxy.ts
import { checkBotId } from "botid/server";
import { NextRequest, NextResponse } from "next/server";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
const API_KEY = process.env.API_SECRET_KEY ?? "";
export async function verifyAndProxy(
request: NextRequest,
backendPath: string,
): Promise<NextResponse> {
const verification = await checkBotId();
if (verification.isBot && !verification.isVerifiedBot) {
return NextResponse.json({ error: "Blocked" }, { status: 403 });
}
const body = await request.text();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (API_KEY) headers["x-api-key"] = API_KEY;
const clientIp = request.headers
.get("x-forwarded-for")
?.split(",")[0]
?.trim();
if (clientIp) headers["x-real-ip"] = clientIp;
const res = await fetch(`${API_URL}${backendPath}`, {
method: "POST",
headers,
body,
});
const data = await res.text();
return new NextResponse(data, {
status: res.status,
headers: { "Content-Type": "application/json" },
});
}

checkBotId() reads the challenge headers from the incoming request automatically — no arguments needed in Next.js. If the session is a bot and not a verified bot, we return 403 immediately. If it’s human or a verified bot (like ChatGPT Operator or Perplexity — see the full directory at bots.fyi), we forward everything to the backend exactly like the proxy middleware would.

Each route handler is four lines:

app/api/botid/magic-link/route.ts
import type { NextRequest } from "next/server";
import { verifyAndProxy } from "../_proxy";
export async function POST(request: NextRequest) {
return verifyAndProxy(request, "/api/auth/magic-link");
}

Same pattern for /verify, /validate-code, and /redeem-code. Each one maps to a backend auth endpoint.

The last piece: tell your proxy middleware to leave these routes alone. If you have a middleware that rewrites /api/* routes to your backend (like I do), you need to exclude /api/botid/* so the route handlers actually run:

proxy.ts
const ROUTE_HANDLER_ROUTES: string[] = ["/api/botid"];

Then update your frontend auth calls to hit the new paths:

lib/auth.ts
// Before
authFetch("/api/auth/magic-link", { method: "POST", body });
// After
authFetch("/api/botid/magic-link", { method: "POST", body });

What happens locally

BotID always returns isBot: false in development. Your auth flow works exactly the same — the bot check just passes through. If you want to test the blocking behavior locally, pass developmentOptions:

const verification = await checkBotId({
developmentOptions: { bypass: "BAD-BOT" },
});

In production, BotID has two tiers. Basic is free and uses client/network signals. Deep Analysis costs $1 per 1,000 checks and does asynchronous investigation of suspicious sessions. You enable Deep Analysis in the Vercel dashboard under Firewall → Rules — it’s a toggle, not a code change.

For auth endpoints that send emails or hit a database, $1 per 1,000 checks is worth it. A single bot that signs up 10,000 fake accounts costs way more in email sends and database bloat.

Handling verified bots

Not all bots are bad. ChatGPT Operator, Perplexity, and other AI assistants are verified bots that you probably want to let through. BotID (v1.5.0+) tells you when a bot is verified via isVerifiedBot, verifiedBotName, and verifiedBotCategory.

That’s why the check above uses verification.isBot && !verification.isVerifiedBot — it blocks scrapers and credential stuffers while letting verified bots interact with your app normally. Vercel maintains a directory of verified bots at bots.fyi.

If you need finer control, you can check verifiedBotName directly:

const { isBot, isVerifiedBot, verifiedBotName } = await checkBotId();
// Only allow specific verified bots
const allowedBots = ["chatgpt-operator", "perplexitybot"];
const isAllowed = isVerifiedBot && allowedBots.includes(verifiedBotName ?? "");
if (isBot && !isAllowed) {
return NextResponse.json({ error: "Blocked" }, { status: 403 });
}

Trevor I. Lasn

Building 0xinsider.com — the Bloomberg terminal for prediction markets. Product engineer based in Tartu, Estonia, building and shipping for over a decade.


Found this article helpful? You might enjoy my free newsletter. I share dev tips and insights to help you grow your coding skills and advance your tech career.


Related Articles

Check out these related articles that might be useful for you. They cover similar topics and provide additional insights.

Webdev
14 min read

What's New in Next.js 16: Every Change Explained

Complete guide to Next.js 16 — async params, Turbopack default, stable caching APIs, and how to upgrade. With code examples.

Oct 25, 2025
Read article
Webdev
4 min read

LH and RLH: The CSS Units That Make Vertical Spacing Easy

Exploring new CSS line-height units that eliminate guesswork from vertical rhythm

Dec 3, 2024
Read article
Webdev
6 min read

Micro Frontends: The LEGO Approach to Web Development

Explore the concept of micro frontends in web development, understand their benefits, and learn when this architectural approach is most effective for building scalable applications.

Oct 2, 2024
Read article
Webdev
4 min read

CSS :interest-source and :interest-target Pseudo-Classes

Style connected UI elements with CSS pseudo-classes that respond to user interest. Interactive examples showing tooltips, forms, and navigation without JavaScript.

Nov 12, 2025
Read article
Webdev
5 min read

Peaks.js — Interact With Audio Waveforms

Peaks.js is a client-side JavaScript component to display and interact with audio waveforms in the browser

Oct 22, 2019
Read article
Webdev
36 min read

IndexNow: Get Pages Indexed in Minutes, Not Weeks

Set up IndexNow to notify Bing, DuckDuckGo, ChatGPT, and Perplexity the moment you publish. Free protocol, 5-minute setup.

Oct 27, 2025
Read article
Webdev
3 min read

CSS ::target-text for Text Highlighting

A look at how browsers can highlight text fragments using CSS ::target-text, making text sharing and navigation more user-friendly

Dec 17, 2024
Read article
Webdev
8 min read

View Transitions API: Smooth animations between DOM states

Create animated transitions between different states of your app without complex animation libraries.

Nov 11, 2025
Read article
Webdev
4 min read

Self-Taught Developer's Guide to Thriving in Tech

How to turn your non-traditional background into your biggest asset

Sep 28, 2024
Read article

This article was originally published on https://www.trevorlasn.com/blog/vercel-botid-bot-protection-nextjs. It was written by a human and polished using grammar tools for clarity.