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.