Korzen
Bot Protection

Next.js

Bot Protection in een Next.js-project met de App Router: de tag in de root layout, de call in een server action, het IP-adres van de bezoeker op Vercel en achter een proxy.

Deze gids is voor de App Router, Next.js 15 en nieuwer. Je voegt de tag toe aan de root layout, en roept Bot Protection aan vanuit de server action of route handler die het account aanmaakt.

De tag

In de root layout, zodat elke pagina hem heeft:

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

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 in lib/ 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 één functie voor elke beschermde actie:

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

Daarna, in de server action die het account aanmaakt:

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

In een route handler pakt withKorzen hem in: bij block antwoordt hij 403, en hij geeft de handler het antwoord na zijn eigen argumenten:

// 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 },
);

Het IP-adres van de bezoeker

Op Vercel bevat x-forwarded-for het IP-adres van de bezoeker: Vercel overschrijft de header, dus een bezoeker kan er niet zijn eigen waarde in zetten. Elders lees je de header die je eigen edge zet: cf-connecting-ip achter Cloudflare, x-real-ip als je nginx die zet. Achter een proxy die aan x-forwarded-for toevoegt in plaats van hem te overschrijven, is de eerste entry wat de bezoeker maar stuurde: neem de laatste die je proxy heeft toegevoegd.

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. Of stuur de bezoeker naar onze challengepagina, op challenge.url: zie de challengepagina.

Zonder onChallenge laat de middleware geen challenge door: het laden van een pagina (GET) gaat naar onze challengepagina, en een verstuurd formulier of een API-call wordt geweigerd met 403. Bescherm ook de pagina die het formulier toont: daar krijgt een persoon de challenge, en zijn pass laat het formulier door. Heb je een eigen controle, geef dan onChallenge en voer hem daar uit. De middleware stuurt de persoon terug naar het opgevraagde adres, gelezen uit X-Forwarded-Host en X-Forwarded-Proto als je proxy die zet; achter een proxy die Host: localhost doorgeeft en geen van beide (standaard bij nginx), geef je publicURL: "https://shop.example".

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

Draai next dev en open http://localhost:3000. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 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.local 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 Beslissingen van het dashboard, onder het pad dat je stuurde.
  3. Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met 401, en de registratie gaat door.

Veelgemaakte fouten

  • De eerste entry van x-forwarded-for lezen achter een proxy die eraan toevoegt. Een bot kiest dan zijn eigen IP-adres.
  • Een formulier dat JavaScript verstuurt zodra de pagina geladen is, voordat de tag heeft gerapporteerd. Roep await window.korzen?.ready() aan voordat je het verstuurt; dat is binnen een paar seconden klaar, ook als Bot Protection traag is.
  • De call in middleware. Die draait bij elke request, en elke call telt als beslissing: roep hem alleen aan in de actie die je beschermt.
  • /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.