There is no Rust SDK: this guide calls the API with reqwest, the HTTP client most Rust servers already have. One reqwest::Client for the process keeps its connections open, so a call costs one round trip and not a new TLS handshake, and its timeout bounds the whole call, from connecting to reading the body.
Payment, login and sign-up routes: turn on requireToken ("require_token": true in the call), so a script that never loads your page cannot take the action. Protecting a checkout
The tag
Put the tag in the <head> of the layout that renders your sign-up, sign-in and checkout pages:
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
In the page, before the form is sent, call await window.korzen?.ready(): it reports what the visitor did since the tag last reported, and resolves within a few seconds whatever happens. The tag
The call
Behind Cloudflare? A Worker can do this without changing your app. Cloudflare Workers
The crates, in Cargo.toml:
# Cargo.toml
[dependencies]
axum = "0.8"
reqwest = { version = "0.13", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
A module that holds the client and the calls. decide answers None on any error, any status but 200 and any timeout: the caller then lets the visitor through.
// src/korzen.rs: one client for the process, shared by every request. use std::time::Duration; use serde_json::{Map, Value, json}; /// The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. pub(crate) const FORWARDED: [&str; 11] = [ "accept", "accept-encoding", "accept-language", "host", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests", ]; /// What the call says about the visitor. #[derive(Debug, Default)] pub(crate) struct Visitor { pub(crate) ip: String, pub(crate) user_agent: String, /// The `_kz` cookie the tag set. pub(crate) token: String, /// The [`FORWARDED`] headers the request has. pub(crate) headers: Vec<(String, String)>, /// The request's header names in the order they came, at most 64; empty when the /// server does not keep it. pub(crate) header_order: Vec<String>, } #[derive(Debug, Clone)] pub(crate) struct Korzen { http: reqwest::Client, url: String, key: String, } impl Korzen { /// The client: `timeout` bounds the whole call, from connecting to reading the body. pub(crate) fn new(url: &str, key: &str, timeout: Duration) -> reqwest::Result<Self> { let http = reqwest::Client::builder().timeout(timeout).build()?; let url = url.trim_end_matches('/').to_owned(); Ok(Self { http, url, key: key.to_owned(), }) } /// The answer for `visitor` before the action at `path`, or `None` on any error or /// timeout: then go on as on allow (fail open). pub(crate) async fn decide(&self, visitor: &Visitor, path: &str) -> Option<Value> { let mut body = fields(visitor, path); body.insert("ip_from".to_owned(), json!("peer")); // see "The visitor's IP" body.insert("method".to_owned(), json!("POST")); // Sign-up, login and payment: a script that never loaded your page holds no token. body.insert("require_token".to_owned(), json!(true)); let res = self.post("/v1/decide", &Value::Object(body), None).await?; res.json().await.ok() } /// Tells Bot Protection what you learned later about the session of a decision: /// `good_customer`, `verified_human`, `chargeback` or `fraud`. `key` makes a resend count /// once: an order's id, say. False when it did not get there. pub(crate) async fn feedback(&self, request_id: &str, outcome: &str, key: &str) -> bool { let body = json!({"feedback": [{"request_id": request_id, "outcome": outcome}]}); self.post("/v1/feedback", &body, Some(key)).await.is_some() } /// Reports a request your server served without asking first: `"page"` for a page a /// person loads, `"api"` for a call a script makes. False when it did not get there. pub(crate) async fn event(&self, visitor: &Visitor, kind: &str, path: &str) -> bool { let mut event = fields(visitor, path); event.insert("kind".to_owned(), json!(kind)); let body = json!({"events": [event]}); self.post("/v1/events", &body, None).await.is_some() } /// The answer to a POST of `body` to `route`, or `None` unless it is a 200. async fn post( &self, route: &str, body: &Value, key: Option<&str>, ) -> Option<reqwest::Response> { let mut req = self .http .post(format!("{}{route}", self.url)) .bearer_auth(&self.key) .json(body); if let Some(key) = key { req = req.header("Idempotency-Key", key); } let res = req.send().await.ok()?; (res.status() == reqwest::StatusCode::OK).then_some(res) } } /// What the decide call and an event both say about the request. fn fields(visitor: &Visitor, path: &str) -> Map<String, Value> { let headers: Map<String, Value> = visitor .headers .iter() .map(|(name, value)| (name.clone(), json!(value))) .collect(); let mut f = Map::new(); f.insert("ip".to_owned(), json!(visitor.ip)); f.insert("user_agent".to_owned(), json!(visitor.user_agent)); f.insert("headers".to_owned(), Value::Object(headers)); f.insert("header_order".to_owned(), json!(visitor.header_order)); f.insert("token".to_owned(), json!(visitor.token)); f.insert("path".to_owned(), json!(path)); f }
Axum
A middleware on the routes that create an account or take a payment. It asks before the handler runs, refuses block and challenge as a failed sign-up does, and hands the handler the decision in the request's extensions:
// src/protect.rs (Axum): the middleware on the routes that create an account. use std::net::SocketAddr; use axum::extract::{ConnectInfo, Request, State}; use axum::http::{HeaderMap, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use serde_json::Value; use super::korzen::{FORWARDED, Korzen, Visitor}; /// Asks Bot Protection before the handler. On block or challenge it answers as a failed /// sign-up does; otherwise the handler runs, with the decision in the request's extensions. pub(crate) async fn protect( State(korzen): State<Korzen>, ConnectInfo(peer): ConnectInfo<SocketAddr>, mut req: Request, next: Next, ) -> Response { let visitor = visitor(peer, req.headers()); let decision = korzen.decide(&visitor, req.uri().path()).await; let action = decision .as_ref() .and_then(|d| d.get("action")) .and_then(Value::as_str); if matches!(action, Some("block" | "challenge")) { // "challenge" is refused too: with a CAPTCHA or an email or phone check of yours, // run it here instead. return (StatusCode::BAD_REQUEST, "We could not create your account.").into_response(); } if let Some(d) = decision { req.extensions_mut().insert(d); // keep its request_id with the account } next.run(req).await } /// The visitor of a request from `peer`, the TCP peer. pub(crate) fn visitor(peer: SocketAddr, h: &HeaderMap) -> Visitor { let get = |name: &str| { h.get(name) .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_owned() }; let token = h .get_all("cookie") .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|c| c.split(';')) .find_map(|c| c.trim().strip_prefix("_kz=")) .unwrap_or_default() .to_owned(); Visitor { ip: peer.ip().to_string(), user_agent: get("user-agent"), token, headers: FORWARDED .iter() .filter(|n| h.contains_key(**n)) .map(|n| ((*n).to_owned(), get(n))) .collect(), header_order: h.keys().take(64).map(|k| k.as_str().to_owned()).collect(), } }
Then, in main, the client once for the process and the middleware on the route. The server is served with ConnectInfo, so the middleware sees the address the connection came from:
// src/main.rs (Axum) mod korzen; mod protect; use std::net::SocketAddr; use std::time::Duration; use axum::middleware::from_fn_with_state; use axum::response::Redirect; use axum::routing::post; use axum::{Extension, Router}; use serde_json::Value; #[tokio::main] async fn main() { let key = std::env::var("KORZEN_API_KEY").unwrap_or_default(); // The timeout for your server's region: https://korzen.dev/bot-protection/docs/server-call#latency let korzen = korzen::Korzen::new("https://korzen.dev", &key, Duration::from_millis(200)) .expect("an HTTP client"); let app = Router::new().route( "/signup", post(signup).route_layer(from_fn_with_state(korzen.clone(), protect::protect)), ); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .expect("port 3000"); // ConnectInfo gives the middleware the TCP peer's address. axum::serve( listener, app.into_make_service_with_connect_info::<SocketAddr>(), ) .await .expect("the server"); } /// The decision is there unless the call failed. async fn signup(decision: Option<Extension<Value>>) -> Redirect { let request_id = decision .as_ref() .and_then(|Extension(d)| d.get("request_id")?.as_str()); // Create the account here, and keep request_id with it. println!("sign-up, decision {request_id:?}"); Redirect::to("/welcome") }
header_order is the order the request's headers came in: hyper reads them in that order, and Axum's HeaderMap keeps it. Behind nginx, a load balancer or Cloudflare, it is the order that hop forwards them in, not the browser's.
Actix Web
The same module and the same call, in the handler that creates the account, with the client registered once as web::Data:
// Actix Web: the same call, in the handler that creates the account. Actix keeps the // headers in a hash map, so their order is lost: header_order stays empty. use actix_web::{HttpRequest, HttpResponse, web}; use crate::korzen::{FORWARDED, Korzen, Visitor}; async fn signup(req: HttpRequest, korzen: web::Data<Korzen>) -> HttpResponse { let h = req.headers(); let get = |name: &str| { h.get(name) .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_owned() }; let visitor = Visitor { ip: req .peer_addr() .map(|a| a.ip().to_string()) .unwrap_or_default(), user_agent: get("user-agent"), token: req .cookie("_kz") .map(|c| c.value().to_owned()) .unwrap_or_default(), headers: FORWARDED .iter() .filter(|n| h.contains_key(**n)) .map(|n| ((*n).to_owned(), get(n))) .collect(), header_order: Vec::new(), }; let decision = korzen.decide(&visitor, "/signup").await; let action = decision .as_ref() .and_then(|d| d.get("action")) .and_then(|a| a.as_str()); if matches!(action, Some("block" | "challenge")) { return HttpResponse::BadRequest().body("We could not create your account."); } // Create the account here, and keep decision's request_id with it. HttpResponse::SeeOther() .insert_header(("Location", "/welcome")) .finish() }
The visitor's IP
The code above sends the address the connection came from, with "ip_from": "peer". That is the visitor only when nothing stands in front of your server. Behind a load balancer or nginx, send the address it put in X-Forwarded-For instead, with "ip_from": "last_forwarded", and only when every request comes through it. Behind Cloudflare, send CF-Connecting-IP, with "ip_from": "cloudflare", if your server only takes traffic from Cloudflare.
Apply the action
Read only action. On block, answer with the same error the action shows when it fails for any other reason, so a bot learns nothing. On challenge, use the check you already have (a CAPTCHA, an email or phone code); with none, refuse it as you refuse a block. On sign-in, sign-up and checkout, never let a challenge through: most scripts that send a browser's User-Agent are answered challenge, not block. The account starts in monitor mode, where action is always allow: nothing changes for visitors until you turn on block mode.
Keep the request_id
Every answer has a request_id. Keep it with what the action created, in a column of the account or the order, or in a log line with their id. It names that decision when you ask us about it, and lets you find out later which answer let a fake account in.
Feedback and events
When you learn more about a visitor later, an order paid without dispute or a chargeback, send it with that decision's request_id: korzen.feedback(&request_id, "good_customer", &order_id). The key, here the order's id, makes a resend count once. Feedback
A request your server serves without asking first, such as a page load, can be reported after the fact: korzen.event(&visitor, "page", "/pricing"). Events and feedback are free, and neither changes what the visitor sees, so send them after the response, never before it.
Test it on your machine
Run cargo run and open http://localhost:3000. Pages on localhost always accept the tag and are never listed as a domain. Locally the IP is 127.0.0.1 or ::1, and that is fine. In Docker on your machine, the app sees the bridge's address instead, such as 172.17.0.1: the challenge page still brings you back to localhost, since the tag saw your page there.
- Set
KORZEN_API_KEYin the environment and open the sign-up page. In the browser's developer tools, the_kzcookie appears after a few seconds. - Sign up. The decision shows on the dashboard's Decisions page, under the path you sent.
- Check fail open: set a wrong key and sign up again. The call answers
401, and the sign-up goes through.
Common mistakes
- A new
reqwest::Clientper call. Each opens a TLS connection to Germany: one or two more round trips. - Serving the app without
into_make_service_with_connect_info.ConnectInfothen fails, and Axum answers500on the route. - Calling
/v1/decidefrom the browser. The API key would reach every visitor; the call belongs on your server. - No timeout, or an error that stops the sign-up. Any answer other than
200, and no answer in time, means: let the visitor through.