# Korzen Bot Protection: set-up guide for coding agents

You are adding Korzen Bot Protection to a project. It tells the server, in one call,
whether the visitor behind a sign-up, a sign-in or a checkout is a person or a bot. Follow
these steps exactly, change nothing else, and show the user the diff.

- API: https://korzen.dev
- Site key (public, goes in the page): YOUR_SITE_KEY
- API key (secret): read it from the `KORZEN_API_KEY` environment variable. Never write
  the key into the code, a commit or a log.

If the site key above is `YOUR_SITE_KEY`, ask the user for theirs: it is on
https://korzen.dev/app/setup.

## 1. The tag

Add this to the `<head>` of every page where a sign-up, a sign-in or a checkout starts
(the layout that renders those pages, if there is one):

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

It loads asynchronously and sets a first-party cookie, `_kz`, that the server sends on
step 2. Before the protected form is sent, call `await window.korzen?.ready()` (if the
form is a plain HTML form, send it from a submit handler after that call): it reports
what the visitor did since the tag last reported, and always resolves within a few
seconds.

## 2. The call from the server

In the server code that handles the protected action, before the account is created (or
the sign-in accepted, or the order placed), call:

```
POST https://korzen.dev/v1/decide
Authorization: Bearer $KORZEN_API_KEY
Content-Type: application/json
Korzen-Deadline-Ms: 200

{
  "ip": "<the visitor's IP, as the project's proxy or CDN reports it>",
  "user_agent": "<the User-Agent header>",
  "headers": {"<header name, lower-cased>": "<its value>", …},
  "token": "<the _kz cookie, if present>",
  "path": "<the action's path, such as /signup>",
  "method": "POST"
}
```

Rules that must hold:

- **Timeout of 200 ms**, sent as `Korzen-Deadline-Ms: 200`. Use the
  HTTP client's timeout or an abort signal; the header tells Bot Protection how long the
  call waits, and the decision takes half of it.
- **One HTTP client for the process**, kept open (keep-alive): Bot Protection runs in
  Germany, and a new TLS connection per call adds one or two round trips. If the server runs
  far from Europe (the US west, South America), raise the timeout, and the header with it
  (300 or 500 ms).
- **Fail open.** On a timeout, a network error, a non-200 status (401, 402, 503 …) or a
  body that does not parse, treat the answer as `allow`. An error's body is an RFC 9457
  problem (`application/problem+json`) whose `code` says why (`unknown_field` names a
  field Bot Protection does not know): log it, never show it. Never let Bot Protection stop a
  sign-up by being down.
- **Server side only.** Never call `/v1/decide` from the browser; the API key must not
  reach it.
- **The headers.** In `headers`, forward each of these the request carries, under its
  lower-cased name, as it came: `accept`, `accept-encoding`, `accept-language`, `sec-ch-ua`,
  `sec-ch-ua-mobile`, `sec-ch-ua-platform`, `sec-fetch-dest`, `sec-fetch-mode`,
  `sec-fetch-site`, `upgrade-insecure-requests`; leave out the ones it does not carry. Every
  copy of one browser build sends the same values, so a script that copies a browser's
  User-Agent and not the rest stands out. From Node, also send `header_order`: the names of
  the request's headers in the order they came, lower-cased (every other entry of
  `req.rawHeaders`); where the order is lost (Go's `net/http`, the Fetch API's `Headers`),
  leave it out.
- **The visitor's real IP.** Take it from what the project's proxy or CDN sets
  (`CF-Connecting-IP` on Cloudflare; `x-forwarded-for` on Vercel, which overwrites it;
  Express's `req.ip` with `trust proxy` set), never from a header the visitor could have
  written: a bot would pick its own IP. Nor the connection's address behind a load
  balancer: a private address (`10.x`, `172.16–31.x`, `192.168.x`, `100.64–127.x`,
  `fc00::/7`) is the project's own network's, and Bot Protection judges it as no IP.

The answer:

```json
{
  "request_id": "AQEAo3k9Zx4VbQ…",
  "class": "bot",
  "bot_score": 72,
  "human_score": 0,
  "reasons": ["behaviour"],
  "rules": [],
  "step_up": false,
  "action": "allow",
  "intended_action": "challenge",
  "mode": "monitor",
  "challenge": { "url": null, "passed": false },
  "token_required": false,
  "seen_before": 0,
  "crawler": null
}
```

## 3. Apply the action

Read only `action`:

- `allow`: go on.
- `challenge`: use the project's existing CAPTCHA, email or phone verification before
  going on. If the project has none, go on; do not add a CAPTCHA library.
- `block`: refuse with the same error the action shows when it fails for any other
  reason (such as "We could not create your account"), so a bot learns nothing.

The account starts in monitor mode, where `action` is always `allow`: the integration
changes nothing for visitors until the user turns block mode on in the dashboard.

## 4. Configuration

- Add `KORZEN_API_KEY=` to the project's example environment file (`.env.example` or
  similar), with no value.
- Do not add a dependency for the call: use the platform's HTTP client. In Node or Go, the
  client Korzen serves may be copied into the project instead, one file with no
  dependencies that fails open: https://korzen.dev/bot-protection/sdk/korzen.js (types in
  korzen.d.ts) and https://korzen.dev/bot-protection/sdk/korzen.go; the Express, Next.js and Go
  guides show it.

## Examples

Next.js (App Router), called from the route handler or server action that creates the
account:

```js
import { cookies, headers } from "next/headers";

// The headers Bot Protection reads: those one browser build sends alike, and Accept-Language.
const FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
  "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"];

export async function decide(path) {
  const h = await headers();
  try {
    const res = await fetch("https://korzen.dev/v1/decide", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.KORZEN_API_KEY}`, "Content-Type": "application/json",
        "Korzen-Deadline-Ms": "200",
      },
      body: JSON.stringify({
        ip: h.get("x-forwarded-for")?.split(",")[0].trim(),
        user_agent: h.get("user-agent"),
        headers: Object.fromEntries(FORWARDED.filter((n) => h.has(n)).map((n) => [n, h.get(n)])),
        token: (await cookies()).get("_kz")?.value,
        path,
        method: "POST",
      }),
      signal: AbortSignal.timeout(200),
    });
    return res.ok ? (await res.json()).action : "allow";
  } catch {
    return "allow";
  }
}
```

Express:

```js
// The headers Bot Protection reads: those one browser build sends alike, and Accept-Language.
const FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
  "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"];

async function decide(req, path) {
  try {
    const r = await fetch("https://korzen.dev/v1/decide", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.KORZEN_API_KEY}`, "Content-Type": "application/json",
        "Korzen-Deadline-Ms": "200",
      },
      body: JSON.stringify({
        ip: req.ip, user_agent: req.get("user-agent"), token: req.cookies?._kz, path, method: "POST",
        headers: Object.fromEntries(FORWARDED.filter((n) => req.get(n) !== undefined).map((n) => [n, req.get(n)])),
        header_order: req.rawHeaders.filter((_, i) => i % 2 === 0).map((n) => n.toLowerCase()), // the order they came in
      }),
      signal: AbortSignal.timeout(200),
    });
    return r.ok ? (await r.json()).action : "allow";
  } catch {
    return "allow";
  }
}
```

Python:

```python
import os, requests

korzen = requests.Session()  # one per process: it keeps the connection open

# The headers Bot Protection reads: those one browser build sends alike, and Accept-Language.
FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
             "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"]

def decide(ip, headers, token, path):
    """headers: the request's, as Django's or Flask's request.headers (case-insensitive)."""
    try:
        r = korzen.post("https://korzen.dev/v1/decide", timeout=0.2,
            headers={"Authorization": f"Bearer {os.environ['KORZEN_API_KEY']}",
                     "Korzen-Deadline-Ms": "200"},
            json={"ip": ip, "user_agent": headers.get("User-Agent"),
                  "headers": {h: headers[h] for h in FORWARDED if h in headers},
                  "token": token, "path": path, "method": "POST"})
        return r.json()["action"] if r.status_code == 200 else "allow"
    except Exception:
        return "allow"
```

Guides per stack, with where the tag goes, the visitor's real IP on that platform and the
common mistakes: read the one that matches the project before you change it.

- Next.js: https://korzen.dev/bot-protection/docs/nextjs.md
- Express: https://korzen.dev/bot-protection/docs/express.md
- Fastify and NestJS: https://korzen.dev/bot-protection/docs/fastify-nestjs.md
- Hono, Bun, Deno, React Router and Remix, Astro, SvelteKit and Nuxt (the Fetch API's
  `Request`): https://korzen.dev/bot-protection/docs/web-request.md
- Django and Flask: https://korzen.dev/bot-protection/docs/django-flask.md
- FastAPI: https://korzen.dev/bot-protection/docs/fastapi.md
- Rails: https://korzen.dev/bot-protection/docs/rails.md
- Laravel: https://korzen.dev/bot-protection/docs/laravel.md
- PHP without a framework, and Symfony: https://korzen.dev/bot-protection/docs/php.md
- Go net/http: https://korzen.dev/bot-protection/docs/go.md
- Spring Boot: https://korzen.dev/bot-protection/docs/spring-boot.md
- ASP.NET Core: https://korzen.dev/bot-protection/docs/aspnet-core.md
- Supabase Auth (sign-ups through the server, and a Before User Created hook):
  https://korzen.dev/bot-protection/docs/supabase.md
- Clerk (one decision on a new account's first page): https://korzen.dev/bot-protection/docs/clerk.md
- Auth0 (a pre-user-registration Action): https://korzen.dev/bot-protection/docs/auth0.md
- Better Auth (a database hook before any user is created):
  https://korzen.dev/bot-protection/docs/better-auth.md
- Kubernetes (the key as a Secret, the visitor's IP through the ingress):
  https://korzen.dev/bot-protection/docs/kubernetes.md
- Cloudflare Workers (the decision at the edge, before the origin):
  https://korzen.dev/bot-protection/docs/cloudflare-workers.md

## 5. Tell the user

When done, tell the user to:

1. Set `KORZEN_API_KEY` where the server runs (the key is on https://korzen.dev/app/setup).
2. Deploy. There is no list of origins to fill in and no DNS record: the server's first
   decision on a visitor of a page on a domain, one that is not a bot, adds that domain
   under "Your domains" on the same page (`www.`, the bare name and every other subdomain
   count as one). Pages on localhost always work and are never listed, and the tag's first
   beacon on one lets block mode turn on for that machine. A domain there that is not
   theirs, such as a copy of the site, can be dropped, and stays dropped for 30 days; one
   of theirs can be added by hand on the same page (on the free trial it is protected
   once the server's first decision on a person there proves it).
3. Watch the first decisions on https://korzen.dev/app/decisions. The free trial starts at the first
   call to `/v1/decide` and lasts 14 days or 10,000 decisions. The account stays in monitor
   mode until they turn block mode on.

More: https://korzen.dev/bot-protection/docs
