Korzen
Bot Protection

Cloudflare Workers

Bot Protection in a Cloudflare Worker, in front of your origin: the decision at the edge, CF-Connecting-IP, and the request on to your app.

When your site is behind Cloudflare, a Worker on the routes you protect can ask Bot Protection before the request reaches your origin: no change to your application at all. It runs at Cloudflare's edge, close to korzen.dev, which is behind Cloudflare too. On a static site, a Cloudflare Pages Function does the same for one action, as on our own blog.

The tag

The tag still goes on your pages, in the <head>, so the _kz cookie is there when the visitor submits:

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

The Worker

The client is one file with no dependencies: it asks /v1/decide, gives up at your timeout and lets the visitor through on any error. Download it next to your Worker, and read it before you commit it:

curl -o src/bot-protection.js https://korzen.dev/bot-protection/sdk/korzen.js

createWorker is the whole Worker: it asks about every request on its routes with your timeout, and takes its settings from the Worker's environment:

// src/index.js: a Worker on the routes Bot Protection judges, in front of the cache and your origin.
// wrangler.toml:
//   routes = [
//     { pattern = "tickets.example/api/resale/*", zone_name = "tickets.example" },
//     { pattern = "tickets.example/orders/*", zone_name = "tickets.example" },
//   ]
//   [vars]
//   KORZEN_URL = "https://korzen.dev"
//   KORZEN_TIMEOUT_MS = "200"
//   KORZEN_REQUIRE_TOKEN = "/orders/*"
// The key: npx wrangler secret put KORZEN_API_KEY
import { createWorker } from "./bot-protection.js";

export default createWorker();
  • KORZEN_API_KEY: your secret key, as a Worker secret. Without it every request goes on unjudged, and the Worker's log says so once.
  • KORZEN_URL: https://korzen.dev.
  • KORZEN_TIMEOUT_MS: how long a decision may take, 10 to 1000 ms (200 unless set). Past it, the request goes on.
  • KORZEN_REQUIRE_TOKEN: the paths whose actions need the tag's token, separated by commas; one ending in * is a prefix.

On block the Worker answers 403. On challenge, in block mode, a page load goes to the challenge page and comes back with a token, which it keeps as the _kz cookie; a challenged form post or API call gets 403. Everything else goes on to Cloudflare's cache and your origin, with Korzen-Request-Id and Korzen-Action headers your application can log. Any error or timeout lets the request through: when Bot Protection is slow or down, your site works as it did without the Worker.

Actions that need a token

An action on a path in KORZEN_REQUIRE_TOKEN (its POST, PUT, PATCH or DELETE: a page load spends no token) goes on only with a token the tag earned in that browser, once per session. A script that never runs JavaScript is refused on every attempt, whatever address it comes from. Put the tag on the page that leads to the action, and that page on the Worker's routes too, so a person meets any challenge when it loads.

Polling served from the cache

The Worker runs before Cloudflare's cache: a request the cache answers never reaches your server, but it reaches the Worker. Put the Worker on the routes that are polled (an inventory, a seat map) and every poll is judged; the ones allowed are still served from the cache. Each request on the Worker's routes is a decision on your plan's volume, so route only what you want judged.

What Cloudflare adds

With each decision the Worker sends what Cloudflare measured of the visitor (request.cf): on every plan the network (ASN), the country, the HTTP version and the TCP round trip between the visitor and Cloudflare (none over HTTP/3); with Enterprise Bot Management also the JA4 fingerprint and the detection ids. Bot Protection holds that round trip against the one the tag measured on the page: a recording made on another network claims a round trip the visitor's own connection cannot have. None of them blocks a visitor alone.

Your own handler

createWorker covers the usual case. For a Worker that does more, wrap your own fetch with withKorzen; it sends what Cloudflare measured just the same.

withKorzen wraps the Worker's fetch: it reads what Cloudflare knows about the visitor and asks with your timeout. On block it answers 403 itself. On challenge, in block mode, a page load goes to the challenge page and comes back with a token, which it keeps as the _kz cookie: the tag carries that session on, and its next decisions are allow. Everything else goes on to your origin, with the decision's id and action in headers your application can log:

// src/index.js: a Worker on the routes you protect, in front of your origin.
// wrangler.toml:  routes = [{ pattern = "shop.example/signup*", zone_name = "shop.example" }]
// The key:        npx wrangler secret put KORZEN_API_KEY
import { createClient, ipFrom, withKorzen } from "./bot-protection.js";

let protect; // made once per isolate, with the key from env

export default {
  fetch(request, env, ctx) {
    protect ??= withKorzen(
      async (request, env, ctx, d) => {
        // on to your origin, with the decision's id and action
        const forward = new Request(request);
        if (d?.request_id) forward.headers.set("Korzen-Request-Id", d.request_id);
        if (d?.action) forward.headers.set("Korzen-Action", d.action);
        return fetch(forward);
      },
      {
        client: createClient({ apiKey: env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 }),
        ip: ipFrom.cloudflare, // CF-Connecting-IP: Cloudflare sets it
      },
    );
    return protect(request, env, ctx);
  },
};

A challenged form post cannot go to the page and back: withKorzen refuses it with 403. With a check of your own at the origin, pass onChallenge: () => undefined, and the post reaches your origin with Korzen-Action: challenge. Put the page that shows the form on the Worker's routes too, and the visitor is challenged when it loads.

The visitor's IP

CF-Connecting-IP is set by Cloudflare and cannot be written by the visitor, so a Worker always has the right address. Your origin should still accept traffic from Cloudflare only, or a bot can skip the Worker by calling it directly.

The headers and their order

withKorzen sends the headers whose values a browser build fixes (Accept, Accept-Encoding, the client hints, Sec-Fetch-*) and Accept-Language: a script that copies a browser's User-Agent seldom copies all of them. It does not send their order (header_order): a Worker's request.headers lists the names sorted, not as they came, so the field is left out and the decision reads the rest. On your own Node server, fromNodeRequest sends the order from rawHeaders.

Test it

npx wrangler dev runs the Worker on your machine; set the key in .dev.vars as KORZEN_API_KEY=…. Then npx wrangler deploy, and watch the decisions on the dashboard's Decisions page.

Common mistakes

  • A Worker on every route: each request on its routes is a decision. Route only what you want judged: the polling you watch, sign-up, sign-in, checkout.
  • A path in KORZEN_REQUIRE_TOKEN whose pages have no tag: no visitor holds a token there, so every action on it is refused.
  • Reading the request's body to decide, and then forwarding a body already used: a Request's body can be read once.
  • An origin open to the whole internet: the Worker only guards the requests that go through Cloudflare.