Korzen
Bot Protection

Express

Bot Protection num app Express: a tag, a chamada com o fetch do Node, trust proxy para o IP do visitante e a rota que cria a conta.

Este guia é para Express 4 e 5 no Node 22 em diante. O fetch do Node fecha uma conexão parada há 4 segundos, então um servidor que pergunta com menos frequência abre uma conexão TLS nova na maioria das chamadas; um Agent do undici com um keepAliveTimeout maior, passado como dispatcher, a mantém aberta.

A tag

Coloque a tag no <head> do layout que renderiza as páginas de cadastro, login e checkout:

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

Na página, antes de enviar o formulário, chame await window.korzen?.ready(): ela informa o que o visitante fez desde o último relato da tag e resolve em poucos segundos, aconteça o que acontecer. A tag

A chamada

O cliente é um arquivo só, sem dependências, o korzen.js: ele pergunta ao /v1/decide, desiste no seu timeout e deixa o visitante passar em qualquer erro. Baixe-o para o seu projeto e leia-o antes do 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

Depois, o protect, como middleware na rota que cria a conta. Ele guarda a resposta em req.korzen, ou null quando não há resposta:

// 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 em 2026-10-18: o Cloudflare não envia o aviso Keep-Alive, então do Node 18 ao 26 o fetch sozinho abriu uma conexão nova depois de 5 segundos parada, e com o Agent uma só conexão atendeu chamadas a 120 segundos uma da outra. Instale o undici 7: o Agent dele funciona com o fetch do Node 20 ao 26. O undici 8, que um npm install undici simples instala hoje, falha em toda chamada no Node 22 e 24 (invalid onRequestStart method), e uma chamada que falha deixa o visitante passar, então só as decisões que faltam mostram isso.

Para perguntar dentro de um handler, await korzen.decide(fromNodeRequest(req, { ip: (_get, r) => r.ip, path: "/signup" })) devolve a resposta, ou null em qualquer erro. Uma resposta cujo action não é allow, challenge ou block também conta como erro. Para registrar o motivo, dê ao createClient um onAnswer: depois de cada chamada ele recebe o status, o Korzen-Request-Id, os headers RateLimit e, num erro, o code do problem.

O IP do visitante

req.ip é o endereço da conexão, a menos que você diga ao Express quantos proxies há na frente. Defina trust proxy com esse número (1 para um load balancer ou um nginx): o Express pega o endereço que esse proxy acrescentou ao X-Forwarded-For e ignora o que o visitante tiver colocado antes. true confia na primeira entrada, que o visitante pode forjar. Atrás do Cloudflare, passe ip: ipFrom.cloudflare ao protect, se o seu servidor só recebe tráfego do Cloudflare.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode. Ou mande o visitante para a nossa página de desafio, em challenge.url: veja a página de desafio.

Sem onChallenge, o middleware não deixa passar nenhum desafio: um carregamento de página (GET) vai para a nossa página de desafio, e um envio de formulário ou uma chamada de API é recusado com 403. Proteja também a página que mostra o formulário, para que a pessoa encontre o desafio ali e a aprovação dela deixe o envio passar. Com uma verificação sua, passe onChallenge e rode-a ali. O middleware manda a pessoa de volta ao endereço que ela pediu, lido de X-Forwarded-Host e X-Forwarded-Proto quando seu proxy os define; atrás de um proxy que repassa Host: localhost e nenhum dos dois (o padrão do nginx), dê publicURL: "https://shop.example".

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa. Quando a conta se mostrar boa ou ruim, avise: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (veja feedback).

Teste na sua máquina

Rode o servidor com node --env-file=.env app.js e abra em localhost. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.

  1. Defina KORZEN_API_KEY em .env e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • app.set("trust proxy", true). Confia em todo salto, e o req.ip vira o que o visitante escreveu no X-Forwarded-For.
  • Nenhum trust proxy atrás de um load balancer. Todo visitante fica com o IP privado do balancer, que o Bot Protection toma por nenhum IP: ele vê bem menos, e a página Configuração avisa você.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.
  • Uma linha no seu log que começa com korzen: the visitor's IP sent to Bot Protection. O cliente a escreve uma vez, na primeira decisão cujo IP é da sua própria rede (de um load balancer, de um proxy, de um contêiner), que o Bot Protection julga como sem IP: defina ip como o endereço do visitante (o IP do visitante). No Docker na sua máquina, é o endereço da bridge, e ali não faz mal.