Articles · Authentication & Factors

Four layers before the database

Try out the running example

Article 001 mentioned it in passing: before a login touches the database, “the abuse gate checked four rate-limit layers”. This article is that sentence, unpacked. The gate exists because password endpoints are attacked in shapes — one IP hammering one account, one IP walking a breach list across thousands of accounts, a botnet distributing guesses against one victim — and no single counter catches all of them. Four keyed counters, checked in order, before any I/O an attacker could enjoy.

The four layers

# Layer Counter key What increments it
1 Per-IP the address every attempt, success or failure, any account
2 Per-IP+account address + identifier every attempt for that pair
3 Account lockout the account failed verifications only, from anywhere
4 Credential stuffing the address distinct identifiers tried, not raw attempts

Evaluation is 1→4 with short-circuit, and the gate runs before user lookup — a blocked attempt costs the attacker a counter increment, not an argon2id verification, and leaks nothing about whether the account exists.

Each layer’s shape is what defeats its attack, and the sample isolates each one. Layer 1 counts everything from an address — after three attempts, the right password gets the same 429 as the wrong one, while the same account from a different IP sails (Layer1_per_ip_counts_all_attempts_and_blocks_the_ip_not_the_account). Layer 3 is the mirror image: failures against one account from three different addresses lock the account everywhere, and the lock is a TTL, not an operator ticket —

// Layer3_locks_the_account_across_all_ips_and_expires_by_clock
host.Clock.UtcNow += TimeSpan.FromMinutes(16);
var recovered = await host.AttemptAsync(GateWorld.AliceEmail, GateWorld.Password, ip: "198.51.100.1");
recovered.StatusCode.Should().Be(HttpStatusCode.OK);

Layer 4 is the one that catches what layers 1–3 structurally cannot: low-and-slow stuffing, a few attempts each against many accounts. It counts distinct identifiers per address — re-trying a seen identifier doesn’t advance it, the fourth new one trips it (Layer4_counts_distinct_identifiers_per_ip_not_raw_attempts). The credential-stuffing recipe covers the attack side.

Tuning

Everything lives on SentinelAbuseOptions; the sample’s demo profile shows the shape (production defaults in the comment):

// file: GateComposition.cs
public static SentinelAbuseOptions DemoOptions() => new()
{
    CaptchaEnabled = true, // the soft band: past 50% of a threshold, humans may proceed
    PerIp = new AbuseLayerOptions { Threshold = 10, Window = TimeSpan.FromMinutes(5) },
    PerIpAccount = new AbuseLayerOptions { Threshold = 8, Window = TimeSpan.FromMinutes(15) },
    AccountLockout = new AccountLockoutOptions
    {
        Threshold = 5, Window = TimeSpan.FromMinutes(15), LockoutDuration = TimeSpan.FromMinutes(15),
    },
    CredentialStuffing = new AbuseLayerOptions { Threshold = 20, Window = TimeSpan.FromMinutes(10) },
};

Every layer has Enabled, and windows are sliding TTLs on the counter — each increment refreshes the expiry, so “10 in 5 minutes” means five minutes of quiet resets it, not a fixed bucket boundary.

What the caller sees: two prices

A tripped layer answers 429 blocked — stable code, localized message, and deliberately not which layer fired. But a hard wall punishes the forgetful human as much as the bot, so there’s a softer band first: with CaptchaEnabled, once a counter passes CaptchaFactor (default 0.5) of its threshold, the answer becomes 429 captcha_required, carrying the public siteKey. The client re-sends the same login request with a captchaToken; a valid token lets the attempt through the band. Past the full threshold, captcha stops helping — the hard limit is not negotiable.

{ "status": 429, "error": "captcha_required", "siteKey": "demo-site-key", "message": "" }

The provider side is one interface. Real hosts wire Turnstile/hCaptcha/reCAPTCHA with AddSentinelCaptcha; the sample registers an offline stand-in so the flow is walkable:

// file: GateComposition.cs
public sealed class DemoCaptchaVerifier(string accepted) : ICaptchaVerifier
{
    public ValueTask<bool> VerifyAsync(
        string token, string? ip, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(token == accepted);
}

Past_half_the_threshold_humans_solve_a_captcha_and_proceed_bots_stall walks the whole band: three free attempts, the challenge, a wrong token challenged again (never escalated), the solved token logging in.

One note on the anti-enumeration posture: 429s are per-IP or per-pair almost always — you are hammering, you get told. The account-lockout layer answering 429 is the deliberate exception to the “locked looks like wrong password” default mentioned in article 001: once an attacker is past the failure threshold, uniform 401s would punish the legitimate owner more than they hide.

When the counter store dies

The counters live behind IRateCounterStore — in-process by default, the ValKey adapter when a fleet needs shared counters. So what happens when that store is down? The port contract is explicit: the store throws, it never decides policy. Policy is per-layer:

PerIp = new AbuseLayerOptions
{
    Threshold = 3, Window = TimeSpan.FromMinutes(5),
    FailureMode = AbuseFailureMode.FailOpen,   // or FailClosed
},

FailOpen (the default) chooses availability: the layer passes, and the outage is recorded as an abuse.counter_store_unavailable event — your monitoring’s problem, not your users’. FailClosed chooses protection: attempts answer 429 blocked for the duration, indistinguishable from a real block. Both are wrong for someone, which is why it’s a per-layer choice and not a hardcoded opinion — A_dead_counter_store_fails_open_or_closed_per_layer_policy runs the same dead store through both and asserts the opposite outcomes.

What this layer is not

The gate is deterministic bookkeeping: counters, thresholds, windows. It knows nothing about who is attempting — a familiar device from a familiar country scores the same as a Tor exit node until a threshold trips. That judgment call is a different machine with a different contract: the risk engine, which is article 014. The authentication page shows where both sit in the login pipeline.