El registro de Clerk va del navegador a Clerk, y Clerk no tiene un hook que se ejecute antes de crear un usuario. Así que Bot Protection decide justo después: la primera página a la que llega una cuenta nueva pregunta una vez por ella, la banea con block y la marca como revisada; hasta entonces, tu app no le deja hacer nada. Es el patrón de onboarding del propio Clerk, con Next.js.
El tag
Como en la guía de Next.js, en el root layout, dentro de <ClerkProvider>. La función decide también es la de esa guía.
La llamada
En el dashboard de Clerk, en Sessions, añade los metadatos públicos del usuario al session token, para que tu servidor lea la marca sin llamar a la API:
{
"metadata": "{{user.public_metadata}}"
}
La server action que pregunta una vez y marca la cuenta:
// app/signup/check/actions.js "use server"; import { auth, clerkClient } from "@clerk/nextjs/server"; import { decide } from "@/lib/korzen"; // from the Next.js guide // check asks Bot Protection once about a new account, and marks it checked. export async function check() { const { userId } = await auth(); if (!userId) return "signed-out"; const client = await clerkClient(); const user = await client.users.getUser(userId); if (user.publicMetadata.korzen) return "done"; let d = null; if (Date.now() - user.createdAt < 24 * 3600_000) { // accounts made before you added Bot Protection are marked without a decision d = await decide("/signup"); if (d?.action === "block") { await client.users.banUser(userId); // ends its sessions; it cannot sign in again return "blocked"; } // "challenge": ask for a phone number, or another check, before going on } await client.users.updateUserMetadata(userId, { publicMetadata: { korzen: { request_id: d?.request_id ?? null } }, }); return "done"; }
La página a la que llegan las cuentas nuevas. Envíalas allí con NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/signup/check:
// app/signup/check/page.js "use client"; import { useUser } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { check } from "./actions"; export default function Check() { const { isLoaded, user } = useUser(); const router = useRouter(); useEffect(() => { if (!isLoaded) return; (async () => { await window.korzen?.ready(); if ((await check()) !== "done") return router.push("/sign-in"); await user?.reload(); // a new session token, with the mark in it router.push("/dashboard"); })(); }, [isLoaded]); return <p>One moment…</p>; }
Un middleware que redirige allí a las cuentas sin marca. Para Clerk es solo una comodidad: en un route handler o una server action que gaste dinero o envíe mensajes, comprueba sessionClaims?.metadata?.korzen otra vez.
// middleware.js (proxy.js on Next.js 16) import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; const isOpen = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)", "/signup/check"]); export default clerkMiddleware(async (auth, req) => { const { userId, sessionClaims } = await auth(); if (userId && !sessionClaims?.metadata?.korzen && !isOpen(req)) { return NextResponse.redirect(new URL("/signup/check", req.url)); } }); // keep the config.matcher your Clerk set-up already has
La IP del visitante
La decisión se toma en tu server action, así que la IP es lo que dice la guía de Next.js. La protección contra bots del propio Clerk en el registro puede seguir activa: protege el formulario de Clerk, y Bot Protection la cuenta que creó.
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.
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 con tus claves de desarrollo de Clerk 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
- Comprobar la marca solo en el middleware. Un bot llama a tus rutas de API directamente: comprueba el claim donde importa.
- No llamar a
user.reload()tras marcar la cuenta. El session token conserva los metadatos viejos hasta un minuto, y el middleware sigue redirigiendo la cuenta a la comprobación. - Preguntar otra vez en cada visita. Cada llamada cuenta como decisión: gracias a la marca se pregunta una sola vez por cuenta.
- 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.