Korzen
Bot Protection

Express

Bot Protection in einer Express-App: der Tag, der Aufruf mit Nodes fetch, trust proxy für die IP des Besuchers und die Route, die das Konto anlegt.

Diese Anleitung gilt für Express 4 und 5 auf Node 22 oder neuer. Das fetch von Node schließt eine Verbindung nach 4 Sekunden Leerlauf, ein Server, der seltener fragt, öffnet also für die meisten Aufrufe eine neue TLS-Verbindung; ein undici-Agent mit längerem keepAliveTimeout, als dispatcher übergeben, hält sie offen.

Der Tag

Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten rendert:

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

Rufen Sie auf der Seite vor dem Senden des Formulars await window.korzen?.ready() auf: Es meldet, was der Besucher seit der letzten Meldung des Tags getan hat, und wird in jedem Fall innerhalb weniger Sekunden aufgelöst. Der Tag

Der Aufruf

Der Client ist eine einzige Datei ohne Abhängigkeiten, korzen.js: Er fragt /v1/decide, gibt bei Ihrem Timeout auf und lässt den Besucher bei jedem Fehler durch. Laden Sie ihn in Ihr Projekt und lesen Sie ihn, bevor Sie ihn committen:

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

Dann protect, als Middleware auf der Route, die das Konto anlegt. Es legt die Antwort in req.korzen ab, oder null, wenn es keine gibt:

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

Gemessen gegen korzen.dev am 2026-10-18: Cloudflare sendet keinen Keep-Alive-Hinweis, also öffnete fetch allein auf Node 18 bis 26 nach 5 Sekunden Leerlauf eine neue Verbindung, und mit dem Agent bediente eine Verbindung Aufrufe im Abstand von 120 Sekunden. Installieren Sie undici 7: Sein Agent funktioniert mit dem fetch von Node 20 bis 26. undici 8, das ein einfaches npm install undici heute liefert, scheitert auf Node 22 und 24 bei jedem Aufruf (invalid onRequestStart method), und ein gescheiterter Aufruf lässt den Besucher durch, also zeigen es nur die fehlenden Entscheidungen.

Um stattdessen in einem Handler zu fragen: await korzen.decide(fromNodeRequest(req, { ip: (_get, r) => r.ip, path: "/signup" })) liefert die Antwort, oder null bei jedem Fehler. Eine Antwort, deren action nicht allow, challenge oder block ist, gilt ebenfalls als Fehler. Um den Grund zu protokollieren, geben Sie createClient ein onAnswer: Nach jedem Aufruf erhält es den Status, Korzen-Request-Id, die RateLimit-Header und bei einem Fehler den code des Problems.

Die IP des Besuchers

req.ip ist die Adresse der Verbindung, solange Sie Express nicht sagen, wie viele Proxys davor stehen. Setzen Sie trust proxy auf diese Zahl (1 für einen Load Balancer oder nginx): Express nimmt dann die Adresse, die dieser Proxy an X-Forwarded-For angehängt hat, und ignoriert, was der Besucher davor geschrieben hat. true vertraut dem Eintrag ganz links, den ein Besucher schreiben kann. Hinter Cloudflare übergeben Sie protect stattdessen ip: ipFrom.cloudflare, wenn Ihr Server nur Traffic von Cloudflare annimmt.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten. Oder schicken Sie den Besucher zu unserer Challenge-Seite unter challenge.url: siehe die Challenge-Seite.

Ohne onChallenge lässt die Middleware keine Challenge durch: Ein Seitenaufruf (GET) geht zu unserer Challenge-Seite, ein abgeschicktes Formular oder ein API-Aufruf wird mit 403 abgelehnt. Schützen Sie auch die Seite, die das Formular zeigt: Dort trifft eine Person auf die Challenge, und ihr Pass lässt das Formular durch. Mit einer eigenen Prüfung geben Sie onChallenge an und führen sie dort aus. Die Middleware schickt die Person an die aufgerufene Adresse zurück, gelesen aus X-Forwarded-Host und X-Forwarded-Proto, wenn Ihr Proxy sie setzt; hinter einem Proxy, der Host: localhost und keinen der beiden weiterreicht (nginx standardmäßig), geben Sie publicURL: "https://shop.example" an.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat. Wenn sich das Konto als gut oder schlecht herausstellt, melden Sie es zurück: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (siehe Feedback).

Auf Ihrem Rechner testen

Starten Sie Ihren Server mit node --env-file=.env app.js und öffnen Sie ihn auf localhost. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung. Läuft die App auf Ihrem Rechner in Docker, sieht sie stattdessen die Adresse der Bridge, etwa 172.17.0.1: Die Challenge-Seite bringt Sie trotzdem zu localhost zurück, denn der Tag hat Ihre Seite dort gesehen.

  1. Setzen Sie KORZEN_API_KEY in .env und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • app.set("trust proxy", true). Es vertraut jedem Hop, sodass req.ip zu dem wird, was der Besucher in X-Forwarded-For geschrieben hat.
  • Gar kein trust proxy hinter einem Load Balancer. Jeder Besucher hat dann die private IP des Balancers, die Bot Protection wie keine IP behandelt: Es sieht viel weniger, und die Seite Einrichtung weist Sie darauf hin.
  • /v1/decide aus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.
  • Eine Zeile in Ihrem Log, die mit korzen: the visitor's IP sent to Bot Protection beginnt. Der Client schreibt sie einmal, bei der ersten Entscheidung, deren IP aus Ihrem eigenen Netz stammt (die eines Load Balancers, eines Proxys, eines Containers), was Bot Protection wie keine IP wertet: Setzen Sie ip auf die Adresse des Besuchers (die IP des Besuchers). In Docker auf Ihrem Rechner ist es die Adresse der Bridge, und dort harmlos.