Korzen
Bot Protection

Better Auth

Bot Protection met Better Auth: een database hook die vraagt voordat er een gebruiker wordt aangemaakt, met een wachtwoord of via een social provider, en de registratie weigert bij block.

Better Auth draait op je eigen server, dus het request dat een bezoeker registreert gaat door je code. Een databaseHooks.user.create.before-hook draait voordat Better Auth een gebruiker aanmaakt, vanuit het formulier met e-mail en wachtwoord of vanuit de callback van een social provider, en krijgt de headers van het request: daar vraagt hij Bot Protection, en bij block weigert hij de gebruiker. Deze gids is voor Better Auth 1.x.

De tag

Zoals in de gids van je framework (Next.js, Express), in de layout van je pagina's, zodat de registratiepagina hem heeft:

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

De call

De client is één bestand zonder dependencies: hij vraagt /v1/decide, geeft op bij je timeout en laat de bezoeker door bij elke fout. Download hem naast je Better Auth-configuratie en lees hem voordat je hem 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

Daarna de hook, en een veld op de gebruiker dat de request_id van de beslissing bewaart:

// lib/auth.js
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
import { createClient, fromHeaders, ipFrom } from "./bot-protection.js";

const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 });

export const auth = betterAuth({
  // your database, emailAndPassword, socialProviders ...
  user: {
    additionalFields: { korzenRequestId: { type: "string", required: false, input: false } },
  },
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          if (!ctx?.headers) return; // a user your own code creates, outside a request
          const d = await korzen.decide(
            fromHeaders(ctx.headers, {
              ip: ipFrom.vercel, // your platform's: see "The visitor's IP"
              path: "/signup",
              method: ctx.request?.method ?? "POST",
            }),
          );
          if (d?.action === "block") {
            throw new APIError("BAD_REQUEST", { message: "We could not create your account." });
          }
          // "challenge": ask for a phone number, or another check, before the account does anything
          return { data: { ...user, korzenRequestId: d?.request_id ?? null } };
        },
      },
    },
  },
});

Daarna één keer npx auth@latest generate (of migrate, met de eigen Kysely-adapter van Better Auth), zodat de database de nieuwe kolom heeft.

Het IP-adres van de bezoeker

De hook draait op je server, dus het IP is wat de gids van je platform zegt: ipFrom.vercel op Vercel, ipFrom.cloudflare als je server alleen verkeer van Cloudflare aanneemt, ipFrom.lastForwarded achter je eigen load balancer of nginx. Een header die de bezoeker kan schrijven laat een bot zijn eigen IP kiezen.

Voer de actie uit

Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet.

Bewaar de request_id

Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet.

Test het op je eigen machine

Start je server en open de registratiepagina op localhost. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP 127.0.0.1 of ::1, en dat is prima. In Docker op je eigen machine ziet de app in plaats daarvan het adres van de bridge, zoals 172.17.0.1: de challengepagina brengt je toch terug naar localhost, want de tag zag je pagina daar.

  1. Zet KORZEN_API_KEY in .env en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de _kz-cookie.
  2. Registreer je. De beslissing verschijnt op de pagina Decisions van het dashboard, onder /signup, en de nieuwe gebruiker heeft zijn korzenRequestId.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • Een hooks.before alleen op /sign-up/email. Registraties met Google of GitHub komen daar nooit langs; de database hook ziet elke nieuwe gebruiker.
  • Ook vragen in session.create. Elke call telt als een beslissing: hier is één per nieuw account genoeg; bescherm inloggen en checkout in hun eigen handlers.
  • /v1/decide aanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.
  • Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan 200, en geen antwoord op tijd, betekent: laat de bezoeker door.
  • Een regel in je log die begint met korzen: the visitor's IP sent to Bot Protection. De client schrijft hem één keer, bij de eerste beslissing waarvan het IP van je eigen netwerk is (van een load balancer, een proxy, een container), die Bot Protection beoordeelt als zonder IP: zet ip op het adres van de bezoeker (het IP van de bezoeker). In Docker op je eigen machine is het het adres van de bridge, en daar onschuldig.