Korzen
Bot Protection

Express

Bot Protection in een Express-app: de tag, de call met de fetch van Node, trust proxy voor het IP-adres van de bezoeker, en de route die het account aanmaakt.

Deze gids is voor Express 4 en 5 op Node 22 of nieuwer. De fetch van Node sluit een verbinding na 4 seconden zonder verkeer, dus een server die minder vaak vraagt opent bij de meeste calls een nieuwe TLS-verbinding; een undici-Agent met een langere keepAliveTimeout, meegegeven als dispatcher, houdt hem open.

De tag

Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's rendert:

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

Roep op de pagina, voordat het formulier wordt verstuurd, await window.korzen?.ready() aan: het meldt wat de bezoeker deed sinds de tag het laatst meldde, en is hoe dan ook binnen een paar seconden klaar. De tag

De call

De client is één bestand zonder dependencies, korzen.js: hij vraagt /v1/decide, geeft op bij je timeout en laat de bezoeker door bij elke fout. Download hem in je project en lees hem voordat je hem commit:

curl -o korzen.js https://korzen.dev/bot-protection/sdk/korzen.js
curl -o korzen.d.ts https://korzen.dev/bot-protection/sdk/korzen.d.ts # types, for TypeScript

Daarna protect, als middleware op de route die het account aanmaakt. Hij bewaart het antwoord in req.korzen, of null als er geen is:

// app.js
import express from "express";
import { Agent } from "undici"; // optional (npm install undici@7): keeps the connection open between calls
import { createClient, protect } from "./korzen.js";

const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200, dispatcher: new Agent({ keepAliveTimeout: 300_000 }) });
const app = express();
app.set("trust proxy", 1); // one proxy of yours in front: see "The visitor's IP"
app.use(express.urlencoded({ extended: false }));

const guard = protect({
  client: korzen,
  ip: (_get, req) => req.ip, // with "trust proxy" set: see "The visitor's IP"
  onBlock: (req, res) => res.status(400).send("We could not create your account."),
  // onChallenge: (req, res, next, d) => your CAPTCHA, or an email or phone check, then next()
});

app.get("/signup", guard, (req, res) => res.render("signup")); // the form's page: a challenge meets the person here

app.post("/signup", guard, async (req, res) => {
  const user = await createAccount(req.body); // your own code
  await saveRequestID(user.id, req.korzen?.request_id); // see "Keep the request_id"
  res.redirect("/welcome");
});

Gemeten tegen korzen.dev op 2026-10-18: Cloudflare stuurt geen Keep-Alive-hint, dus op Node 18 tot 26 opende fetch alleen na 5 seconden zonder verkeer een nieuwe verbinding, en met de Agent bediende één verbinding calls met 120 seconden ertussen. Installeer undici 7: zijn Agent werkt met de fetch van Node 20 tot 26. undici 8, wat een kale npm install undici vandaag geeft, laat op Node 22 en 24 elke call mislukken (invalid onRequestStart method), en een mislukte call laat de bezoeker door, dus alleen de ontbrekende beslissingen laten het zien.

Om in een handler te vragen: await korzen.decide(fromNodeRequest(req, { ip: (_get, r) => r.ip, path: "/signup" })) geeft het antwoord, of null bij elke fout. Een antwoord waarvan action niet allow, challenge of block is, telt ook als fout. Om te loggen waarom, geef createClient een onAnswer: na elke call krijgt die de status, Korzen-Request-Id, de RateLimit-headers en, bij een fout, de code van het problem.

Het IP-adres van de bezoeker

req.ip is het adres van de verbinding, tenzij je Express vertelt hoeveel proxy's ervoor staan. Zet trust proxy op dat aantal (1 voor één load balancer of nginx): Express neemt dan het adres dat die proxy aan X-Forwarded-For heeft toegevoegd, en negeert wat de bezoeker daarvoor schreef. true vertrouwt de meest linkse entry, en die kan een bezoeker zelf schrijven. Achter Cloudflare geef je protect in plaats daarvan ip: ipFrom.cloudflare, als je server alleen verkeer van Cloudflare accepteert.

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. Blijkt het account goed of slecht, meld het dan terug: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (zie feedback).

Test het op je eigen machine

Start je server met node --env-file=.env app.js en open hem op localhost. 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 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

  • app.set("trust proxy", true). Dat vertrouwt elke hop, dus req.ip wordt wat de bezoeker in X-Forwarded-For schreef.
  • Helemaal geen trust proxy achter een load balancer. Elke bezoeker heeft dan het privé-IP-adres van de balancer, dat Bot Protection als geen IP-adres behandelt: het ziet veel minder, en de pagina Set-up waarschuwt je.
  • /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.