Korzen
Bot Protection

サーバーからの呼び出し

新規登録、ログイン、チェックアウトの前に POST /v1/decide を呼び出します。送る内容、返ってくる内容、Next.js、Express、Go、Python のコードを紹介します。

保護したい操作の前に、サーバーがリクエストについて Bot Protection に問い合わせ、応答の action を適用します。タイムアウトは短くし、どんなエラーでも訪問者を通してください。これが fail-open で、Bot Protection が原因で新規登録が止まることは決してありません。

リクエスト

curl -s -X POST https://korzen.dev/v1/decide \
  -H "Authorization: Bearer $KORZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Korzen-Deadline-Ms: 200" \
  -d '{"ip":"203.0.113.10","user_agent":"Mozilla/5.0 …",
       "headers":{"accept":"text/html,…","accept-language":"en-US","sec-ch-ua":"…","sec-fetch-site":"same-origin"},
       "token":"<the _kz cookie>","path":"/signup","method":"POST"}'

すべてのフィールドは任意ですが、送る情報が多いほど Bot Protection に見えるものも増えます。Bot Protection が知らないフィールド(require_token のつもりの requireToken など)は、その名前を示す 400 unknown_field で拒否されます。タイプミスが既定値として通ることはありません。

headers には、次のヘッダーを訪問者のブラウザーが送ったとおりに小文字の名前で入れ、リクエストにないものは省いてください: 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。同じブラウザービルドのコピーはどれも同じ値を送るため、ブラウザーの User-Agent だけをまねて残りをまねないスクリプトは目立ちます。SDK と下のコードはこのリストを送ります。

Node からは header_order も送ってください。リクエストのヘッダー名を届いた順に小文字で並べたもの(req.rawHeaders の一つおきの要素)で、たとえば ["host", "user-agent", "accept"] です。スクリプトはブラウザのヘッダーの値を、その順序よりもよくまねます。順序が失われる環境(Go の net/http、Fetch API の Headers、Cloudflare Worker)では省いてください。ヘッダー名のリストでない値は無視され、拒否されることはありません。

Bot Protection が保持する長さを超える値は、拒否されずに切り詰められます。User-Agent、パス、各ヘッダーは 512 バイト、ヘッダーは 32 個まで(名前順で先頭から)、トークンは 256 バイトです。SDK は呼び出しを送る前に切り詰めます。独自の呼び出しはそのまま送ってもかまいません。ボディは 256 KiB まで読まれ、この範囲内の呼び出しは必ず収まります。それを超える場合は 256 KiB 以内に完全に届いたフィールドで判定されるので、訪問者が書いた巨大なヘッダー一つで判定が失われることはありません。

訪問者の IP

プロキシや CDN の背後にある場合は、訪問者がヘッダーに書き込めた値ではなく、プロキシや CDN が報告する訪問者の IP を送ってください(Cloudflare なら CF-Connecting-IP、自前のロードバランサーなら X-Forwarded-For に追加したアドレス)。

インターネット上にないアドレス(10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10)は、あなた自身のネットワークのもの、つまりロードバランサー、プロキシ、コンテナのアドレスです。訪問者のアドレスになることはないため、Bot Protection はその判定を IP なしで送られたものとして扱い、見えるものが大きく減ります。ロードバランサーの背後で接続元のアドレスを読むサーバーは、このアドレスを送ってしまいます。判定の大半がこうしたアドレスを含むときは、セットアップページでお知らせします。自分のマシンでテストしている間の 127.0.0.1 と ::1 は問題ありません。同じホスト上のプロキシ(nginx の localhost への proxy_pass)の後ろでは、これらはすべての訪問者についてプロキシのアドレスであり、ページが localhost 上にないことを タグが示した時点で、これもセットアップページでお知らせします。

CDN のエッジのアドレス(Cloudflare、Fastly、CloudFront がそれぞれ公開する範囲のもの)は CDN のもので、その背後のすべての訪問者で同じです。サーバーが、自分のリクエストの送信元アドレスを送っています。Bot Protection はその判定も IP なしで送られたものとして扱い、判定の大半がこうしたアドレスを含むときは、セットアップページでお知らせします。

応答

{
  "request_id": "AQEAo3k9Zx4VbQ…",
  "class": "bot",
  "bot_score": 72,
  "human_score": 0,
  "reasons": ["behaviour", "inconsistency"],
  "rules": [],
  "step_up": false,
  "action": "allow",
  "intended_action": "challenge",
  "mode": "monitor",
  "challenge": { "url": null, "passed": false },
  "token_required": false,
  "seen_before": 0,
  "crawler": null
}
フィールド意味
request_idこの判定の ID(最大 96 文字)。フィードバックと一緒に送るか、個別の応答についてお問い合わせいただく際にお知らせください
bot_score、human_score0〜100。クラスの根拠となった、自動化の証拠と人間の証拠の量
actionすべきこと:allow、challenge、block のいずれか。monitor mode では常に allow
intended_actionblock mode だった場合の応答
classhuman_high、human、unknown、bot、bot_high のいずれか
reasons根拠となった証拠の種類。具体的なルールは含みません:behaviour、inconsistency、environment、missing_signals、history
rules通常は空のリスト []。お客様と一緒にケースを調べるために、私たちがアカウントでルール名を有効にした場合のみ、発火したルールの名前の一覧になります
modemonitor、block、または paused(セットアップページで保護を一時停止したとき。action は allow です)
step_up両方向に強い証拠がある状態。CAPTCHA ではなく、パスキーや 3-D Secure を求めてください
challengeintended_action が challenge のとき:url は、action が challenge のときのこの訪問者用のチャレンジページ(チャレンジページを参照)、それ以外では null。passed は、このセッションが直近 30 分以内にチャレンジページを解いたときに true で、そのとき challenge は allow になります。それ以外では null
token_requiredrequire_token を送り、リクエストがこのアクションに有効な token を持っていなかったとき、またはこのセッションがすでにこのアクションで使った token だったときに true。そのとき action は challenge で、クラスは変わりません(タグを必要とするアクションを参照)。それ以外では false
crawler{"name", "kind"}:自ら名乗り、その提供元が公開しているアドレスから来たクローラー (GPTBot、ClaudeBot、Googlebot など)。クラスは bot のままですが、招いているクローラーなら通すことができます。kind は次のいずれかです:search (検索エンジン)、assistant (AI アシスタントと AI 検索。回答のためにインデックスする、または人が頼んだページを取りに来るもので、学習のためではありません)、scrape (AI の学習とデータセット)、seo (SEO ツール)、preview (リンクプレビュー)、uptime (死活監視サービス)、scan (Censys などのインターネットスキャナー)。どの種類も、Set-up で block にしない限り allow です。ただし scrape と scan は、allow にしない限り block です。AI 学習用クローラーはページをモデルのために持ち去り誰も連れてこず、スキャナーはサイトが公開しているものを調べ上げるだけだからです。種類の選択がそのクローラーの既定となり、クローラーごとに通すかブロックするかも選べます。block mode では、action はその選択です。それ以外では null
seen_before過去 90 日間に、タグのデバイス ID で見て、このブラウザをこのパスに運んだ以前のセッションの数。登録フォームなら、同じ人が別のアカウントを作っているということです。該当なし、タグなし、またはページ表示では 0。Bot Protection が登録として学習したパス(セットアップページに一覧があります)では、以前に見たブラウザは理由 history を加えますが、それだけで bot になることはありません

すべてのメンバーは常に含まれます。値のないオブジェクトは null、空のリストは [] で、null にはなりません。

エラー

200 以外のステータス(誤ったキー、月の上限到達、障害)はすべて、訪問者を通すという意味です。エラーのボディは RFC 9457 の problem(application/problem+json)で、その code が何が起きたかを示します。

{
  "type": "https://korzen.dev/bot-protection/docs/server-call#unknown_field",
  "title": "A field is not known",
  "status": 400,
  "code": "unknown_field",
  "detail": "unknown field `requireToken`, expected one of `ip`, `user_agent`, …",
  "request_id": "rq_01a15441820577808ce489c291023c16"
}
ステータスcodeいつ
400invalid_json, invalid_field, unknown_fieldボディが JSON でない、フィールドの型が違う、または Bot Protection が知らないフィールドがある
401unauthorizedAPI キーがない、または Bot Protection が知らないキー
402over_limit無料トライアルか、その月の判定数を使い切った
402, 403domain_refusedページのドメインが site key で保護されているドメインではない(タグからの呼び出し)
404not_foundそのルートは存在しない
405method_not_allowedそのルートは別のメソッドを受け付ける。Allow がそれを示す
409conflict, idempotency_key_in_use呼び出しが作るものがすでにある、またはこの Idempotency-Key での最初の呼び出しがまだ処理中
413body_too_largeボディがルートの上限を超えている
422idempotency_key_reusedこの Idempotency-Key が 1 時間以内に別のボディで使われた
429rate_limited1 つのネットワークからタグのルートへの呼び出しが多すぎる、または events と feedback で API キーの 1 時間分の Idempotency-Key の記録が上限に達した。Retry-After 付き
503over_capacity, deadline, unavailable同時の呼び出しが多すぎる、deadline 内に判定が出なかった、または Bot Protection が依存するサービスが停止している。Retry-After 付き
500internalBot Protection の障害

すべての応答には Korzen-Request-Id が含まれ、problem の request_id と同じです。呼び出しについてお問い合わせの際はこれをお知らせください。decide、events、feedback には、アカウントの判定数についての IETF の RateLimit-Policy と RateLimit ヘッダーが付きます。"month";q=50000 と "month";r=41234;t=86400 は、その月の 50,000 件のうち 41,234 件が残り、月が 1 日後に終わることを示します(無料トライアルでは "trial")。必要ならログに残し、決してこれを待たないでください。SDK は各呼び出しの後、両方を onAnswer(Go では OnAnswer)に渡します。

サーバーが知っていること

サーバーが自ら確認した事実を送ると、応答の確度が上がります。claims に入れて送ってください。いずれも任意です。

"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
クレーム送るタイミング
account_age_daysログインやチェックアウト時のアカウントの経過日数。30 日以上で考慮されます
known_instrumentアカウントの過去の注文と同じカードやパスキー
device_attestedプラットフォームの構成証明に合格した(パスキー、App Attest、Play Integrity、Private Access Token)
contact_verifiedメールアドレスまたは電話番号が確認済み

訪問者が優良顧客であるというフィードバックがあるとき、known_instrument または device_attested が応答を human_high に引き上げます。送るのは確認したことだけにしてください。クレームは人間であることの証拠として扱われます。

レイテンシーとタイムアウト

Bot Protection はドイツのファルケンシュタインで稼働しています。サーバーが接続を維持している場合、判定にかかる時間はサーバーからのネットワーク往復時間に数ミリ秒を加えた程度です。新しい接続では TLS ハンドシェイクの分、往復が 1〜2 回増えます。以下の例のように、プロセス全体で HTTP クライアントを 1 つだけ使い、サーバーの所在地に応じてタイムアウトを選んでください。それを Korzen-Deadline-Ms(呼び出し全体で待つ時間)として送ります。Bot Protection はその半分を判定に使い(10〜1,000 ms、ヘッダーがなければ 200 ms)、残りの半分をネットワークに残します。半分の時間内に出なかった判定は、すぐに 503 deadline で応答され、カウントされません。SDK は自身のタイムアウトを代わりに送ります。

サーバーの所在地往復時間タイムアウト
ヨーロッパ10–40 ms100–200 ms
米国東部~85 ms200 ms
米国西部~145 ms300 ms
南米~190 ms500 ms

距離に対してタイムアウトが短すぎると、すべての訪問者が通ってしまいますが、その呼び出しも判定としてカウントされます。

Next.js

// lib/korzen.js: call before creating the account, in a route handler or server action.
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", // how long this call waits: the decision takes half
      },
      body: JSON.stringify({
        ip: h.get("x-forwarded-for")?.split(",")[0].trim(), // Vercel sets it; elsewhere, your proxy's header
        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"; // fail open
  }
}

Express

// 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"];

app.post("/signup", async (req, res) => {
  let action = "allow";
  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", // how long this call waits: the decision takes half
      },
      body: JSON.stringify({
        ip: req.ip, user_agent: req.get("user-agent"), token: req.cookies?._kz, path: "/signup", 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),
    });
    if (r.ok) action = (await r.json()).action;
  } catch {} // fail open
  if (action === "block") return res.status(400).send("We could not create your account.");
  // "challenge": your CAPTCHA or email check here. Then create the account.
});

Go

// forwarded are the headers Bot Protection reads: those one browser build sends alike, and
// Accept-Language. net/http keeps no header order, so header_order is left out.
var forwarded = []string{"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"}

func decide(r *http.Request, path string) string {
	var token string
	if c, err := r.Cookie("_kz"); err == nil {
		token = c.Value
	}
	headers := map[string]string{}
	for _, name := range forwarded {
		if v := r.Header.Get(name); v != "" {
			headers[name] = v
		}
	}
	body, _ := json.Marshal(map[string]any{
		"ip": clientIP(r), "user_agent": r.UserAgent(), "headers": headers, "token": token, "path": path, "method": r.Method,
	})
	ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
	defer cancel()
	req, _ := http.NewRequestWithContext(ctx, "POST", "https://korzen.dev/v1/decide", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("KORZEN_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Korzen-Deadline-Ms", "200") // how long this call waits: the decision takes half
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "allow" // fail open
	}
	defer res.Body.Close()
	var ans struct{ Action string `json:"action"` }
	if res.StatusCode != 200 || json.NewDecoder(res.Body).Decode(&ans) != nil {
		return "allow"
	}
	return ans.Action
}

// clientIP is the visitor's address as your edge reports it. Trust only a header your own proxy
// or CDN sets: CF-Connecting-IP behind Cloudflare, the last X-Forwarded-For entry your load
// balancer appended; with none, the connection's address.
func clientIP(r *http.Request) string {
	if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
		return ip
	}
	host, _, _ := net.SplitHostPort(r.RemoteAddr)
	return host
}

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(request, path):
    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"},  # how long this call waits: the decision takes half
            json={"ip": request.remote_addr, "user_agent": request.headers.get("User-Agent"),
                  "headers": {h: request.headers[h] for h in FORWARDED if h in request.headers},
                  "token": request.cookies.get("_kz"), "path": path, "method": "POST"})
        return r.json()["action"] if r.status_code == 200 else "allow"
    except Exception:
        return "allow"  # fail open