Korzen
Bot Protection

Clerk

Bot Protection avec Clerk : une décision sur la première page d'un nouveau compte, un bannissement sur block, et une marque dans le token de session que votre application vérifie.

L'inscription de Clerk va du navigateur à Clerk, et Clerk n'a aucun hook qui s'exécute avant la création d'un utilisateur. Bot Protection décide donc juste après : la première page qu'atteint un nouveau compte l'interroge une fois, le bannit sur block et le marque comme vérifié ; d'ici là, votre application ne le laisse rien faire. C'est le modèle d'onboarding de Clerk lui-même, avec Next.js.

Le tag

Comme dans le guide Next.js, dans le layout racine, à l'intérieur de <ClerkProvider>. La fonction decide vient aussi de ce guide.

L'appel

Dans le tableau de bord de Clerk, sous Sessions, ajoutez les métadonnées publiques de l'utilisateur au token de session, pour que votre serveur lise la marque sans appel d'API :

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

La server action qui interroge une fois et marque le compte :

// 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 page où arrivent les nouveaux comptes. Envoyez-les-y avec 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 qui y renvoie un compte non marqué. Clerk appelle cela une commodité : dans un route handler ou une server action qui dépense de l'argent ou envoie des messages, vérifiez de nouveau sessionClaims?.metadata?.korzen.

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

L'IP du visiteur

La décision s'exécute dans votre server action, donc l'IP est celle qu'indique le guide Next.js. La protection anti-bot de Clerk à l'inscription peut rester active : elle protège le formulaire de Clerk, et Bot Protection le compte qu'il a créé.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Tester sur votre machine

Lancez next dev avec vos clés de développement Clerk et ouvrez http://localhost:3000. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien. Dans Docker sur votre machine, l'app voit à la place l'adresse du bridge, comme 172.17.0.1 : la page de challenge vous ramène quand même sur localhost, puisque le tag y a vu votre page.

  1. Définissez KORZEN_API_KEY dans .env.local et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • La marque vérifiée seulement dans le middleware. Un bot appelle directement vos routes d'API : vérifiez le claim là où cela compte.
  • Pas de user.reload() après la marque. Le token de session garde les anciennes métadonnées jusqu'à une minute, et le middleware renvoie le compte.
  • Interroger à chaque visite. Chaque appel compte comme une décision : la marque le limite à une fois par compte.
  • Appeler /v1/decide depuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.