Korzen
Bot Protection

Clerk

Bot Protection com Clerk: uma decisão na primeira página de uma conta nova, um ban em block e uma marca no session token que o seu app confere.

O cadastro do Clerk vai do navegador para o Clerk, e o Clerk não tem hook que rode antes de criar um usuário. Então o Bot Protection decide logo depois: a primeira página que uma conta nova abre consulta o Bot Protection uma vez, bane a conta em block e a marca como verificada; até lá, o seu app não deixa a conta fazer nada. É o padrão de onboarding do próprio Clerk, com Next.js.

A tag

Como no guia de Next.js, no root layout, dentro do <ClerkProvider>. A função decide também é a daquele guia.

A chamada

No dashboard do Clerk, em Sessions, ponha os metadados públicos do usuário no session token, para o seu servidor ler a marca sem chamar a API:

{
  "metadata": "{{user.public_metadata}}"
}

A server action que pergunta uma vez e marca a conta:

// 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";
}

A página onde as contas novas chegam. Mande-as para lá com 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>;
}

Um middleware que manda de volta uma conta sem marca. O próprio Clerk diz que isso é só uma conveniência: num route handler ou server action que gasta dinheiro ou envia mensagens, confira sessionClaims?.metadata?.korzen de novo.

// 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

O IP do visitante

A decisão roda na sua server action, então o IP é o que o guia de Next.js diz. A proteção contra bots do próprio Clerk no cadastro pode continuar ligada: ela protege o formulário do Clerk, e o Bot Protection a conta que ele criou.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.

Teste na sua máquina

Rode next dev com as chaves de desenvolvimento do Clerk e abra http://localhost:3000. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.

  1. Defina KORZEN_API_KEY em .env.local e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • A marca conferida só no middleware. O bot chama as suas rotas de API direto: confira o claim onde importa.
  • Nenhum user.reload() depois da marca. O session token mantém os metadados antigos por até um minuto, e o middleware manda a conta de volta.
  • Perguntar de novo a cada visita. Cada chamada conta como decisão: com a marca, a pergunta acontece uma vez por conta.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.