Security recipes · 05

Credential stuffing

The threat
Replay breached credential pairs at scale, distributed across IPs and paced to slide under naive rate limits.
Sentinel's counter
Four independent abuse layers (per-IP, per-IP+account, account lockout, distinct-identifier stuffing heuristic) with adaptive CAPTCHA, risk-engine step-up, and per-layer configurable failure modes.

The attack

Credential stuffing is industrialized password reuse: take a combo list from someone else’s breach, replay it against your login through a botnet or residential proxies, and harvest the 0.5–2% of users who reused their password. The economics are the threat model — each attempt is nearly free, so the defense’s job is to bend the cost curve until your login stops being worth the proxy bill.

The attacker’s playbook against rate limits is rotation: many IPs, few attempts per IP, one attempt per account (“password spraying”) to dodge lockouts. A single-layer limiter loses to at least one of those shapes.

How Sentinel counters it

Four layers, four shapes. AbuseProtectionService runs independent sliding windows, each aimed at a different evasion pattern:

Layer Default Catches
Per-IP 30 failures / 5 min the lazy single-source bot
Per-(IP, account) 10 / 15 min focused attacks on one target
Account lockout 5 / 15 min → 15 min lock distributed attacks on one account
Credential stuffing 200 distinct identifiers / IP / 10 min the list replayer

The fourth is the stuffing-specific tell: it counts distinct identifiers per IP, not failures — a stuffing run burns hundreds of different emails from each exit node, which no legitimate NAT population resembles at that rate. Layers trip on exceeding their threshold, and the lockout layer emits abuse.account_locked so the user-visible security feed shows the attack.

CAPTCHA as a cost, not a wall. With CaptchaEnabled, layers escalate to CaptchaRequired at a fraction of their block threshold (CaptchaFactor, default 0.5×): the response is 429 captcha_required carrying the site key, and a solved token (Turnstile/hCaptcha/reCAPTCHA adapters) lets a legitimate user through while multiplying the bot’s per-attempt cost. The lockout layer deliberately never escalates to CAPTCHA — a locked account stays locked.

The risk engine catches what slips through. Attempts that clear the counters still face the deterministic signals — new device (30), impossible travel (40), IP reputation (50), velocity: 5+ distinct IPs per account per hour (25) — mapped to step-up MFA at 40 and block at 80. A stuffing hit on a reused password then meets an MFA challenge the combo list can’t answer, and every decision records its contributing signals (risk.evaluated) — explainable, not vibes.

Anti-enumeration keeps the list unverified. Uniform invalid_credentials and dummy-hash timing (see user enumeration) mean the run can’t even cheaply split your users from the noise.

Failure mode is a decision you make once, calmly. When the counter store is unreachable, each layer follows its configured AbuseFailureMode: default fail-open with a loud abuse.counter_store_unavailable event (an outage shouldn’t lock every customer out), with fail-closed available per layer for realms where the calculus differs. The wrong time to discover this trade-off is during the incident; Sentinel makes it a config property with an alarm attached.

Fleet-ready counters. Single box: in-memory counters, zero infrastructure. Fleet: AddSentinelValKeyHotState(...) shares the windows across instances — a limiter that resets per-pod is a limiter the attacker load-balances around.

What your app must still do

  • Deploy the CAPTCHA adapter and front door. Sentinel returns captcha_required + site key; rendering the widget and posting captchaToken back on retry is your login UI’s job.
  • Push passkeys and MFA adoption. Stuffing monetizes password reuse; every passkey-first account and enrolled TOTP user is a combo-list entry worth nothing. This is the actual fix — the layers buy the time.
  • Feed better signals. The IP-reputation and geo ports are pluggable; wiring a commercial feed (or your own deny-lists) sharpens the 50-point signal. Same for the external risk-score provider port.
  • Watch the events. abuse.account_locked spikes and abuse.counter_store_unavailable both deserve dashboards; the second one deserves a page. And keep your WAF/CDN limits as the outer layer — Sentinel is the application-layer defense, not a substitute for edge volumetric controls.