ASP.NET Core Fraud Detection Without a .NET SDK

A typed HttpClient, an endpoint filter, and the two .NET details that decide whether this works in production: handler lifetime and the JSON property names.

A fisherman in a yellow raincoat casts a net over a lake in the rain
Photo: Fisherman casting net, vembanad lake by Siyaulhaque, CC BY-SA 4.0, cropped. Via Wikimedia Commons.
On this page
  1. Step 1: Add the script to the page with your form
  2. Step 2: Send the check from your server
  3. The field that silently deserialises to zero
  4. An endpoint filter, not middleware
  5. Step 3: Submit your form once
  6. Pass an account id and get multi-accounting for free
  7. Testing
  8. The short version

There is no .NET SDK. For an API with one endpoint that matters, the wrapper is a small typed HttpClient and a record, about eighty lines including the failure handling. Writing it yourself means the timeout and the fail-open policy are decisions you made rather than defaults you inherited.

Two things in those eighty lines are where .NET integrations actually go wrong, and neither has anything to do with fraud: how the HttpClient is registered, and how the JSON maps onto your record. Written for .NET 8 or later with minimal APIs, in the three steps the Maskbreak console lists: add the script to the page with your form, send the check from your server, and submit your form once. It starts in watch mode, where every check is logged and nothing is refused.

Step 1: Add the script to the page with your form

Load the script on the page with your form and put class="monocle-enriched" on the <form> element itself (never on an input):

html
<!-- Views/Shared/_Layout.cshtml -->
<script async src="https://maskbreak.com/assets/sentinel.js"></script>

<!-- Views/Account/Signup.cshtml: posts to the /signup endpoint mapped below -->
<form method="post" action="/signup" class="monocle-enriched">
  @Html.AntiForgeryToken()
  <input type="email" name="email" required>
  <input type="password" name="password" required>
  <button type="submit">Create account</button>
</form>

The script adds three hidden fields to the form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). A JavaScript frontend that posts JSON calls await window.Sentinel.collect() instead (after checking that window.Sentinel exists; submit anyway when it does not) and sends its token, fingerprintEventId and tz in the body. If you send a Content-Security-Policy, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.

Step 2: Send the check from your server

The two obvious ways to get an HttpClient are both wrong, in opposite directions. A new HttpClient() per request exhausts sockets under load. A static readonly HttpClient for the lifetime of the process fixes that and never picks up DNS changes. IHttpClientFactory exists for exactly that pair, so register a typed client:

csharp
// Program.cs
builder.Services.AddHttpClient<MaskbreakClient>((sp, c) =>
{
    // Read when a client is made, from the app's final configuration:
    // an environment variable, user secrets or a key vault (and a test host's settings).
    var key = sp.GetRequiredService<IConfiguration>()["MASKBREAK_API_KEY"] ?? "";
    c.BaseAddress = new Uri("https://maskbreak.com/");
    // Short: this sits in front of a user-facing action.
    c.Timeout = TimeSpan.FromSeconds(3);
    c.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", key);
});

The key is a server-side credential: user secrets in development, an environment variable or key vault in production, and never the browser. The client sends whatever evidence arrived, including none: a check without a token or a browser check id is how the console tells you which half is missing. It never throws a Maskbreak problem at your caller.

csharp
// Services/MaskbreakClient.cs
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed record Verdict(
    [property: JsonPropertyName("decision")]        string Decision,
    [property: JsonPropertyName("risk_score")]      int RiskScore,
    [property: JsonPropertyName("reasons")]         string[]? Reasons,
    [property: JsonPropertyName("device_evidence")] string? DeviceEvidence,
    [property: JsonPropertyName("degraded")]        bool Degraded,
    [property: JsonPropertyName("test")]            bool Test)
{
    public bool IsBlocked => Decision == "block";
    // A complete, production allow: what a strict action needs in enforce mode.
    public bool IsCleanAllow =>
        Decision == "allow" && DeviceEvidence == "ok" && !Degraded && !Test;
}

public sealed record Evidence(string? Token, string? FingerprintEventId, string? Tz, string? Email);

public sealed class MaskbreakClient(
    HttpClient http, ILogger<MaskbreakClient> log, IConfiguration config)
{
    public string Mode { get; } = config["MASKBREAK_MODE"] ?? "watch";  // or "enforce"

    public async Task<Verdict?> CheckAsync(
        Evidence ev, string? accountId, CancellationToken ct)
    {
        var body = new Dictionary<string, string> { ["token"] = ev.Token ?? "" };
        if (!string.IsNullOrEmpty(ev.FingerprintEventId)) body["fingerprintEventId"] = ev.FingerprintEventId;
        if (!string.IsNullOrEmpty(ev.Tz)) body["tz"] = ev.Tz;
        if (!string.IsNullOrEmpty(ev.Email)) body["email"] = ev.Email;
        if (!string.IsNullOrEmpty(accountId)) body["accountId"] = accountId;

        Verdict? v = null;
        try
        {
            using var res = await http.PostAsJsonAsync("v1/evaluate", body, ct);
            if (res.IsSuccessStatusCode)
                v = await res.Content.ReadFromJsonAsync<Verdict>(ct);
            else
                log.LogWarning("maskbreak HTTP {Status}", (int)res.StatusCode);
        }
        catch (TaskCanceledException) when (ct.IsCancellationRequested)
        {
            // The caller went away. Not a vendor problem: do not log it as one.
            throw;
        }
        catch (Exception ex) when (ex is HttpRequestException
                                      or TaskCanceledException
                                      or JsonException)
        {
            // Fail open. A detection outage must not become a checkout outage.
            log.LogWarning("maskbreak unavailable: {Type}", ex.GetType().Name);
        }
        // One line per check. Never log the key, the token or form data.
        log.LogInformation(
            "maskbreak mode={Mode} decision={Decision} reasons={Reasons} device_evidence={DeviceEvidence} degraded={Degraded}",
            Mode, v?.Decision, v?.Reasons is null ? null : string.Join(",", v.Reasons),
            v?.DeviceEvidence, v?.Degraded);
        return v;
    }
}

The ordering of the two catches matters. HttpClient surfaces its own timeout as a TaskCanceledException, the same type you get when the browser disconnects mid-request. Filtering on ct.IsCancellationRequested separates "the vendor was slow, continue without a verdict" from "nobody is listening any more". Collapse them and your logs show a vendor incident every time someone closes a tab.

Try it

Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.

Get an API key

The field that silently deserialises to zero

The response uses snake_case: risk_score, device_evidence. System.Text.Json in ASP.NET Core is case-insensitive by default, which fools people into thinking the mapping is automatic. It is not: case insensitivity does nothing about an underscore, so a property called RiskScore matches nothing and lands on its default value. There is no exception, and a fraud check that always reads as clean is indistinguishable from one that is switched off. Annotate, as above; field names and reason codes on this API are a stability promise, and an explicit JsonPropertyName ties your record to that contract.

An endpoint filter, not middleware

Middleware runs on every request, including static files, health checks and Swagger. An endpoint filter attaches to the routes where a decision is worth making, and takes parameters. It reads the evidence from a posted form, or from a JSON request record that carries it. In watch mode it records the verdict and always continues.

csharp
// Filters/ScreenFilter.cs
public interface IHasEvidence
{
    string? Token { get; }
    string? FingerprintEventId { get; }
    string? Tz { get; }
    string? Email { get; }
}

public sealed class ScreenFilter(bool strict) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var http = ctx.HttpContext;
        var client = http.RequestServices.GetRequiredService<MaskbreakClient>();

        var verdict = await client.CheckAsync(
            await ReadEvidenceAsync(ctx),
            http.User.FindFirst("sub")?.Value,
            http.RequestAborted);
        http.Items["verdict"] = verdict;

        if (client.Mode != "enforce") return await next(ctx);   // watch: log only

        if (verdict?.IsBlocked == true)
            return Results.Json(new { error = "Request declined" },
                                statusCode: StatusCodes.Status403Forbidden);
        if (strict && verdict?.IsCleanAllow != true)
            return Results.Json(new { error = "Additional verification required" },
                                statusCode: StatusCodes.Status409Conflict);
        return await next(ctx);
    }

    static async Task<Evidence> ReadEvidenceAsync(EndpointFilterInvocationContext ctx)
    {
        // A JSON endpoint binds the evidence into its own request record.
        var bound = ctx.Arguments.OfType<IHasEvidence>().FirstOrDefault();
        if (bound is not null)
            return new Evidence(bound.Token, bound.FingerprintEventId, bound.Tz, bound.Email);

        var req = ctx.HttpContext.Request;
        if (!req.HasFormContentType) return new Evidence(null, null, null, null);
        var form = await req.ReadFormAsync();
        return new Evidence(form["monocle"].ToString(), form["sentinel_fp"].ToString(),
                            form["sentinel_tz"].ToString(), form["email"].ToString());
    }
}

Now the policy is one readable line per endpoint:

csharp
// A JSON request record carries the values Sentinel.collect() returned.
public sealed record SignupRequest(string Email, string Password,
    string? Token, string? FingerprintEventId, string? Tz) : IHasEvidence;
public sealed record WithdrawRequest(decimal Amount,
    string? Token, string? FingerprintEventId, string? Tz, string? Email = null) : IHasEvidence;

// The HTML form from step 1 posts its own fields, the three hidden ones included.
app.MapPost("/signup", SignupFormHandler)
   .AddEndpointFilter(new ScreenFilter(strict: false));

// Signup: in enforce mode, refuse only a block. VPN users are customers.
app.MapPost("/api/signup", SignupHandler)
   .AddEndpointFilter(new ScreenFilter(strict: false));

// Money leaving: in enforce mode, only a clean, complete allow continues.
app.MapPost("/api/withdraw", WithdrawHandler)
   .AddEndpointFilter(new ScreenFilter(strict: true));

// Checkout: review costs a step, not the account.
app.MapPost("/api/checkout", async (CheckoutRequest req, HttpContext http, MaskbreakClient mb) =>
{
    var verdict = http.Items["verdict"] as Verdict;
    if (mb.Mode == "enforce" && verdict?.Decision == "review") return await StepUpAsync(req);
    return await ChargeAsync(req);
})
.AddEndpointFilter(new ScreenFilter(strict: false));

A minimal API that binds a form validates antiforgery tokens on .NET 8 and later; keep that, it is your CSRF protection. On controllers the same shape is an IAsyncActionFilter, and on Razor Pages an IAsyncPageFilter. The rule worth holding onto: review must not route to the same place as block, or you have built a hard block with extra steps. There is more on that split in route on the decision, sort on the score.

Step 3: Submit your form once

Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and your log shows one maskbreak line. degraded=True means no network token arrived (the script was blocked, or the class is missing or on an input); device_evidence=not_sent means no browser check id arrived; device_evidence=ok with degraded=False is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce and redeploy. A fixed test token such as test_clean comes back with test: true and never passes a strict route.

Pass an account id and get multi-accounting for free

The account id argument is doing more than logging. When it is present, the same device arriving under a second account becomes linkable, and the response carries device.linked_accounts plus a multi_account_device reason code. Linking is per-customer and hash-only, and it only works with a stable id: the user's sub claim above is stable; a session id or an antiforgery token links nothing.

Testing

The public sandbox key returns fixed answers for the test tokens: test_clean allow, test_vpn review, test_proxy and test_tor block, each marked test: true. Point a WebApplicationFactory at it, in enforce mode:

csharp
public class ScreeningTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private HttpClient Client() => factory
        .WithWebHostBuilder(b => b
            .UseSetting("MASKBREAK_API_KEY", "sk_test_sandbox")
            .UseSetting("MASKBREAK_MODE", "enforce"))
        .CreateClient();

    [Fact]
    public async Task Withdraw_IsRefused_ForAProxy()
    {
        var res = await Client().PostAsJsonAsync("/api/withdraw",
            new { amount = 100, token = "test_proxy" });
        Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
    }
}

For the outage path, add a DelegatingHandler to the typed client in your test host that throws HttpRequestException, and assert what your policy says: a signup still goes through, a withdrawal is held. That is the test people leave out, and it is the one that documents your actual policy.

The short version

  • Script on the page, the class on the <form>, three hidden fields forwarded as token, fingerprintEventId and tz.
  • Register a typed client with AddHttpClient. Never new HttpClient() per request, never a permanent static one.
  • Map risk_score and device_evidence explicitly with JsonPropertyName.
  • Call every time, even without a token, and log one line. Watch mode never refuses.
  • Rethrow TaskCanceledException when the caller cancelled; treat every other failure as no verdict.
  • Endpoint filter, not middleware. In enforce mode: strict on money, blocks only on signup, review to a step-up.

Questions people ask

Is there a .NET SDK for Maskbreak?
No. The official SDKs are Node, Python and PHP. For .NET the integration is a typed HttpClient registered with AddHttpClient plus a record for the response — roughly eighty lines including error handling — which is enough for an API with one endpoint that matters, and it keeps the timeout and failure policy in your own code.
Why is risk_score always 0 in my C# integration?
The property name almost certainly does not match. System.Text.Json is case-insensitive in ASP.NET Core but that does nothing about the underscore, so a RiskScore property never binds to risk_score and takes its default value of zero. Annotate with [JsonPropertyName("risk_score")], or set PropertyNamingPolicy to JsonNamingPolicy.SnakeCaseLower on .NET 8 or later. There is no exception, so the integration looks healthy while every verdict reads as clean.
Middleware or an endpoint filter for fraud checks in ASP.NET Core?
An endpoint filter in almost every case. Middleware runs on every request including static files, health checks and Swagger, so you maintain path prefixes to keep it off the fast path. A filter attaches to the routes where a decision is worth making, takes parameters such as strictness, and short-circuits by returning a result. On controllers the equivalent is an IAsyncActionFilter.
How should a fraud check handle a timeout in ASP.NET Core?
Allow the request. Set HttpClient.Timeout to a few seconds on the typed client (the guide uses three), then catch HttpRequestException, TaskCanceledException and JsonException and return no verdict. One exception: rethrow TaskCanceledException when the request CancellationToken is already cancelled, because that is the caller disconnecting rather than a vendor problem, and swallowing it turns every closed tab into a false outage in your logs.

Paste it in, then watch the verdicts

The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.

Get started freeRead the API docs