This guide is for ASP.NET Core 8 and later, with minimal APIs; in an MVC controller the call is the same. One HttpClient serves the process and keeps the connection open, and a CancellationTokenSource bounds the whole call, the connection included.
The tag
Put the tag in the <head> of the layout your sign-up, sign-in and checkout pages use (Pages/Shared/_Layout.cshtml or Views/Shared/_Layout.cshtml):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
The call
// Korzen.cs using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; public sealed class Korzen(string apiKey) { // The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. static readonly string[] 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"]; // One for the process: it keeps the connection open, and opens a new one every 15 minutes to follow DNS. static readonly HttpClient Http = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(15) }); /// <summary>The answer for this request, or null on any error: then go on as on allow (fail open).</summary> public async Task<JsonElement?> Decide(HttpContext context, string path) { var request = context.Request; var headers = new Dictionary<string, string>(); foreach (var name in Forwarded) { if (request.Headers.TryGetValue(name, out var value)) { headers[name] = value.ToString(); } } var ip = context.Connection.RemoteIpAddress; // see "The visitor's IP" using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); // the whole call try { using var call = new HttpRequestMessage(HttpMethod.Post, "https://korzen.dev/v1/decide") { Content = JsonContent.Create(new Dictionary<string, object?> { ["ip"] = ip is { IsIPv4MappedToIPv6: true } ? ip.MapToIPv4().ToString() : ip?.ToString(), ["user_agent"] = request.Headers.UserAgent.ToString(), ["headers"] = headers, ["token"] = request.Cookies["_kz"], ["path"] = path, ["method"] = "POST", }), }; call.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); call.Headers.Add("Korzen-Deadline-Ms", "200"); using var res = await Http.SendAsync(call, deadline.Token); if (res.StatusCode != HttpStatusCode.OK) { return null; } return await res.Content.ReadFromJsonAsync<JsonElement>(deadline.Token); } catch (Exception) { return null; // a timeout, a network error or an answer that is no JSON } } }
Kestrel keeps no order of the request's headers, so none is sent. Then, in Program.cs, the forwarded headers and the endpoint that creates the account:
// Program.cs using System.Net; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(new Korzen(builder.Configuration["KORZEN_API_KEY"] ?? "")); builder.Services.Configure<ForwardedHeadersOptions>(o => { o.ForwardedHeaders = ForwardedHeaders.XForwardedFor; // your proxy's entry: see "The visitor's IP" // o.KnownProxies.Add(IPAddress.Parse("10.0.0.5")); // your proxy, when it is not on this machine }); var app = builder.Build(); app.UseForwardedHeaders(); app.MapPost("/signup", async (HttpContext context, Korzen korzen) => { var d = await korzen.Decide(context, "/signup"); if (d?.GetProperty("action").GetString() == "block") { return Results.Text("We could not create your account.", statusCode: 400); } // "challenge": your CAPTCHA, or an email or phone check, before going on var user = await CreateAccount(await context.Request.ReadFormAsync()); // your own code await SaveRequestId(user, d?.GetProperty("request_id").GetString()); // see "Keep the request_id" return Results.Redirect("/welcome"); }); app.Run();
The visitor's IP
Connection.RemoteIpAddress is the connection's address, your proxy's if you have one. UseForwardedHeaders with XForwardedFor replaces it with the last entry of X-Forwarded-For, the one your proxy added, but only when the connection comes from a known proxy: loopback by default. Add your load balancer's address to KnownProxies (or its network to the known networks), and call UseForwardedHeaders before anything that reads the IP. Behind Cloudflare, read context.Request.Headers["CF-Connecting-IP"] instead, 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, go on. 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.
Test it on your machine
Run dotnet run and open it on localhost. 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.
- Set
KORZEN_API_KEYin the environment (or withdotnet user-secrets set KORZEN_API_KEY …) 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 load balancer missing from
KnownProxies. The middleware then ignoresX-Forwarded-For, and every visitor has the balancer's private IP, which Bot Protection takes for no IP. - Clearing
KnownProxiesand the known networks to trust every hop. The IP becomes what the visitor wrote inX-Forwarded-For. new HttpClient()for each call. Each one opens its own connection, with a TLS handshake inside your timeout, and can run out of sockets.- 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.