Korzen
Bot Protection

ASP.NET Core

Bot Protection in ASP.NET Core: the tag, one class for the call with one HttpClient under one timeout, the forwarded headers middleware for the visitor's IP, and the endpoint.

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.

  1. Set KORZEN_API_KEY in the environment (or with dotnet user-secrets set KORZEN_API_KEY …) and open the sign-up page. In the browser's developer tools, the _kz cookie appears after a few seconds.
  2. Sign up. The decision shows on the dashboard's Decisions page, under the path you sent.
  3. 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 ignores X-Forwarded-For, and every visitor has the balancer's private IP, which Bot Protection takes for no IP.
  • Clearing KnownProxies and the known networks to trust every hop. The IP becomes what the visitor wrote in X-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/decide from 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.