Korzen
Bot Protection

Express

Bot Protection dans une application Express : le tag, l'appel avec le fetch de Node, trust proxy pour l'IP du visiteur, et la route qui crée le compte.

Ce guide vaut pour Express 4 et 5 sur Node 22 ou plus récent. Le fetch de Node ferme une connexion inactive depuis 4 secondes : un serveur qui demande moins souvent ouvre donc une nouvelle connexion TLS pour la plupart des appels ; un Agent undici avec un keepAliveTimeout plus long, passé comme dispatcher, la garde ouverte.

Le tag

Placez le tag dans le <head> du layout qui rend vos pages d'inscription, de connexion et de checkout :

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

Dans la page, avant d'envoyer le formulaire, appelez await window.korzen?.ready() : il signale ce que le visiteur a fait depuis le dernier signalement du tag, et se résout en quelques secondes quoi qu'il arrive. Le tag

L'appel

Le client tient en un seul fichier sans dépendance, korzen.js : il interroge /v1/decide, abandonne à votre timeout et laisse passer le visiteur à la moindre erreur. Téléchargez-le dans votre projet, et lisez-le avant de le committer :

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

Puis protect, en middleware sur la route qui crée le compte. Il garde la réponse dans req.korzen, ou null quand il n'y en a pas :

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

Mesuré contre korzen.dev le 2026-10-18 : Cloudflare n'envoie pas d'indication Keep-Alive, donc de Node 18 à 26 fetch seul a ouvert une nouvelle connexion après 5 secondes d'inactivité, et avec l'Agent une seule connexion a servi des appels espacés de 120 secondes. Installez undici 7 : son Agent fonctionne avec le fetch de Node 20 à 26. undici 8, que donne aujourd'hui un simple npm install undici, fait échouer chaque appel sur Node 22 et 24 (invalid onRequestStart method), et un appel qui échoue laisse passer le visiteur : seules les décisions manquantes le montrent.

Pour interroger depuis un handler, await korzen.decide(fromNodeRequest(req, { ip: (_get, r) => r.ip, path: "/signup" })) renvoie la réponse, ou null à la moindre erreur. Une réponse dont l'action n'est ni allow, ni challenge, ni block compte aussi comme une erreur. Pour journaliser la raison, donnez à createClient un onAnswer : après chaque appel, il reçoit le statut, Korzen-Request-Id, les headers RateLimit et, sur une erreur, le code du problem.

L'IP du visiteur

req.ip est l'adresse de la connexion tant que vous ne dites pas à Express combien de proxies se trouvent devant. Réglez trust proxy sur ce nombre (1 pour un load balancer ou nginx) : Express prend alors l'adresse que ce proxy a ajoutée à X-Forwarded-For, et ignore ce que le visiteur a écrit avant. true fait confiance à l'entrée la plus à gauche, qu'un visiteur peut écrire. Derrière Cloudflare, passez plutôt à protect ip: ipFrom.cloudflare, si votre serveur ne reçoit du trafic que de Cloudflare.

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. Ou envoyez le visiteur vers notre page de challenge, à challenge.url : voir la page de challenge.

Sans onChallenge, le middleware ne laisse passer aucun challenge : un chargement de page (GET) va vers notre page de challenge, et l'envoi d'un formulaire ou un appel d'API est refusé avec 403. Protégez aussi la page qui affiche le formulaire : une personne y rencontre le challenge, et son pass laisse passer l'envoi. Avec une vérification à vous, passez onChallenge et lancez-la là. Le middleware renvoie la personne à l'adresse demandée, lue dans X-Forwarded-Host et X-Forwarded-Proto quand votre proxy les pose ; derrière un proxy qui transmet Host: localhost et aucun des deux (nginx par défaut), donnez publicURL: "https://shop.example".

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. Quand le compte se révèle bon ou mauvais, renvoyez-le : await korzen.feedback([{ request_id, outcome: "chargeback" }]) (voir le feedback).

Tester sur votre machine

Lancez votre serveur avec node --env-file=.env app.js et ouvrez-le sur localhost. 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 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

  • app.set("trust proxy", true). Il fait confiance à chaque saut, donc req.ip devient ce que le visiteur a écrit dans X-Forwarded-For.
  • Aucun trust proxy derrière un load balancer. Chaque visiteur a alors l'IP privée du load balancer, que Bot Protection traite comme une absence d'IP : il en voit bien moins, et la page Configuration vous prévient.
  • 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.
  • Une ligne de votre log qui commence par korzen: the visitor's IP sent to Bot Protection. Le client l'écrit une fois, à la première décision dont l'IP est celle de votre propre réseau (d'un load balancer, d'un proxy, d'un conteneur), que Bot Protection juge comme sans IP : donnez à ip l'adresse du visiteur (l'IP du visiteur). Dans Docker sur votre machine, c'est l'adresse du bridge, et là elle est sans conséquence.