Korzen
Bot Protection

Express

Bot Protection en una app Express: el tag, la llamada con el fetch de Node, trust proxy para la IP del visitante y la ruta que crea la cuenta.

Esta guía es para Express 4 y 5 en Node 22 o posterior. El fetch de Node cierra una conexión tras 4 segundos inactiva, así que un servidor que pregunta con menos frecuencia abre una conexión TLS nueva en la mayoría de las llamadas; un Agent de undici con un keepAliveTimeout más largo, pasado como dispatcher, la mantiene abierta.

El tag

Pon el tag en el <head> del layout que renderiza tus páginas de registro, inicio de sesión y checkout:

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

En la página, antes de enviar el formulario, llama a await window.korzen?.ready(): informa lo que el visitante hizo desde el último informe del tag y se resuelve en pocos segundos pase lo que pase. El tag

La llamada

El cliente es un solo archivo sin dependencias, korzen.js: pregunta a /v1/decide, se rinde al llegar a tu timeout y deja pasar al visitante ante cualquier error. Descárgalo en tu proyecto y léelo antes de hacer 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

Después, protect, como middleware en la ruta que crea la cuenta. Guarda la respuesta en req.korzen, o null cuando no la hay:

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

Medido contra korzen.dev el 2026-10-18: Cloudflare no envía el aviso Keep-Alive, así que de Node 18 a 26 fetch solo abrió una conexión nueva tras 5 segundos inactiva, y con el Agent una sola conexión sirvió llamadas a 120 segundos una de otra. Instala undici 7: su Agent funciona con el fetch de Node 20 a 26. undici 8, el que da hoy un npm install undici sin versión, falla en cada llamada en Node 22 y 24 (invalid onRequestStart method), y una llamada que falla deja pasar al visitante, así que solo lo muestran las decisiones que faltan.

Para preguntar dentro de un handler, await korzen.decide(fromNodeRequest(req, { ip: (_get, r) => r.ip, path: "/signup" })) devuelve la respuesta, o null ante cualquier error. Una respuesta cuyo action no es allow, challenge o block también cuenta como error. Para registrar el motivo, da a createClient un onAnswer: después de cada llamada recibe el estado, Korzen-Request-Id, los headers RateLimit y, en un error, el code del problem.

La IP del visitante

req.ip es la dirección de la conexión, salvo que le digas a Express cuántos proxies hay delante. Pon trust proxy a ese número (1 para un load balancer o un nginx): Express toma entonces la dirección que ese proxy añadió a X-Forwarded-For e ignora lo que el visitante escribió antes. true confía en la primera entrada, que un visitante puede escribir. Detrás de Cloudflare, pasa a protect ip: ipFrom.cloudflare, si tu servidor solo recibe tráfico de Cloudflare.

Actúa según la action

Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode. O envía al visitante a nuestra página de desafío, en challenge.url: consulta la página de desafío.

Sin onChallenge, el middleware no deja pasar ningún desafío: una carga de página (GET) va a nuestra página de desafío, y el envío de un formulario o una llamada a la API se rechaza con 403. Protege también la página que muestra el formulario, para que la persona encuentre el desafío allí y su pase deje pasar el envío. Con una verificación propia, pasa onChallenge y ejecútala allí. El middleware devuelve a la persona a la dirección que pidió, leída de X-Forwarded-Host y X-Forwarded-Proto cuando tu proxy los pone; detrás de un proxy que reenvía Host: localhost y ninguno de los dos (lo que nginx hace por defecto), da publicURL: "https://shop.example".

Guarda el request_id

Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa. Cuando la cuenta resulte buena o mala, avísanos: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (consulta feedback).

Pruébalo en tu máquina

Arranca tu servidor con node --env-file=.env app.js y ábrelo en localhost. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal. En Docker en tu máquina, la app ve en su lugar la dirección del bridge, como 172.17.0.1: la página de desafío te devuelve igual a localhost, porque el tag vio tu página allí.

  1. Define KORZEN_API_KEY en .env y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie _kz aparece tras unos segundos.
  2. Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
  3. Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde 401, y el registro pasa.

Errores comunes

  • app.set("trust proxy", true). Confía en todos los saltos, y req.ip pasa a ser lo que el visitante escribió en X-Forwarded-For.
  • No configurar trust proxy detrás de un load balancer. Todos los visitantes tienen la IP privada del balancer, que Bot Protection toma por ninguna IP: ve mucho menos, y la página Configuración te avisa.
  • Llamar a /v1/decide desde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor.
  • No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de 200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.
  • Una línea en tu log que empieza por korzen: the visitor's IP sent to Bot Protection. El cliente la escribe una vez, en la primera decisión cuya IP es de tu propia red (la de un balanceador de carga, un proxy, un contenedor), que Bot Protection juzga como sin IP: pon en ip la dirección del visitante (la IP del visitante). En Docker en tu máquina es la dirección del bridge, y ahí no hace daño.