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.
- Capability
- /docs/authentication/

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 postingcaptchaTokenback 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_lockedspikes andabuse.counter_store_unavailableboth 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.