Esta guía es para el App Router, Next.js 15 en adelante. Pones el tag en el root layout y llamas a Bot Protection desde la server action o el route handler que crea la cuenta.
El tag
En el root layout, para que todas las páginas lo tengan:
// app/layout.js export default function RootLayout({ children }) { return ( <html lang="en"> <head> <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script> </head> <body>{children}</body> </html> ); }
La llamada
El cliente es un solo archivo sin dependencias: pregunta a /v1/decide, se rinde al llegar a tu timeout y deja pasar al visitante ante cualquier error. Descárgalo en lib/ y léelo antes de hacer commit:
curl -o lib/bot-protection.js https://korzen.dev/bot-protection/sdk/korzen.js curl -o lib/bot-protection.d.ts https://korzen.dev/bot-protection/sdk/korzen.d.ts # types, for TypeScript
Después, una función para todas las acciones protegidas:
// lib/korzen.js import { headers } from "next/headers"; import { createClient, fromHeaders, ipFrom } from "./bot-protection.js"; export const client = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 }); // decide asks Bot Protection about the request being handled. It returns the answer, // or null on any error: the caller then goes on as on allow (fail open). export async function decide(path) { // ipFrom.vercel on Vercel; see "The visitor's IP" return client.decide(fromHeaders(await headers(), { path, ip: ipFrom.vercel })); }
Luego, en la server action que crea la cuenta:
// app/signup/actions.js "use server"; import { decide } from "@/lib/korzen"; export async function signUp(prevState, formData) { const d = await decide("/signup"); if (d?.action === "block") return { error: "We could not create your account." }; if (d?.action === "challenge") { // your CAPTCHA, or an email or phone check, before going on } const user = await createAccount(formData); // your own code await saveRequestID(user.id, d?.request_id); // see "Keep the request_id" // ... }
En un route handler, withKorzen lo envuelve: responde 403 con block y pasa la respuesta al handler después de sus propios argumentos:
// app/api/signup/route.js import { ipFrom, withKorzen } from "@/lib/bot-protection"; import { client } from "@/lib/korzen"; export const POST = withKorzen( async (request, ctx, d) => { if (d?.action === "challenge") { // your CAPTCHA, or an email or phone check, before going on } const user = await createAccount(await request.formData()); // your own code await saveRequestID(user.id, d?.request_id); return Response.json({ ok: true }); }, // onChallenge answers nothing, so the handler runs its own check; without it, a // challenged post is refused (403) { client, ip: ipFrom.vercel, onChallenge: () => undefined }, );
La IP del visitante
En Vercel, x-forwarded-for trae la IP del visitante: Vercel la sobrescribe, así que un visitante no puede escribir la suya. Fuera de Vercel, lee la cabecera que pone tu propio borde: cf-connecting-ip detrás de Cloudflare, x-real-ip si tu nginx la pone. Detrás de un proxy que añade a x-forwarded-for en vez de sobrescribirla, la primera entrada es lo que envió el visitante: toma la última que añadió tu proxy.
Actúa según la action
Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode. O envía al visitante a nuestra página de desafío, en challenge.url: consulta la página de desafío.
Sin onChallenge, el middleware no deja pasar ningún desafío: una carga de página (GET) va a nuestra página de desafío, y el envío de un formulario o una llamada a la API se rechaza con 403. Protege también la página que muestra el formulario, para que la persona encuentre el desafío allí y su pase deje pasar el envío. Con una verificación propia, pasa onChallenge y ejecútala allí. El middleware devuelve a la persona a la dirección que pidió, leída de X-Forwarded-Host y X-Forwarded-Proto cuando tu proxy los pone; detrás de un proxy que reenvía Host: localhost y ninguno de los dos (lo que nginx hace por defecto), da publicURL: "https://shop.example".
Guarda el request_id
Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa.
Pruébalo en tu máquina
Ejecuta next dev y abre http://localhost:3000. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal. En Docker en tu máquina, la app ve en su lugar la dirección del bridge, como 172.17.0.1: la página de desafío te devuelve igual a localhost, porque el tag vio tu página allí.
- Define
KORZEN_API_KEYen.env.localy abre la página de registro. En las herramientas de desarrollo del navegador, la cookie_kzaparece tras unos segundos. - Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
- Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde
401, y el registro pasa.
Errores comunes
- Leer la primera entrada de
x-forwarded-fordetrás de un proxy que añade entradas a esa cabecera. Así un bot elige su propia IP. - Un formulario enviado por JavaScript nada más cargar la página, antes de que el tag informe. Llama a
await window.korzen?.ready()antes de enviarlo; se resuelve en pocos segundos aunque Bot Protection vaya lento. - La llamada en el middleware. Se ejecuta en cada petición, y cada llamada cuenta como decisión: haz la llamada solo en la acción que proteges.
- Llamar a
/v1/decidedesde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor. - No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de
200, o la falta de respuesta a tiempo, significa: deja pasar al visitante. - Una línea en tu log que empieza por
korzen: the visitor's IP sent to Bot Protection. El cliente la escribe una vez, en la primera decisión cuya IP es de tu propia red (la de un balanceador de carga, un proxy, un contenedor), que Bot Protection juzga como sin IP: pon enipla dirección del visitante (la IP del visitante). En Docker en tu máquina es la dirección del bridge, y ahí no hace daño.