Password Password

Security, explained for the people who have to fix it.

Attack and Defense

Credential stuffing: how it works, how logs show it, how to slow it

Why reused passwords from old breaches become logins on your site, the shape the attack leaves in your auth logs, and the OWASP and ATT&CK controls that limit it.

Credential stuffing is the attack that needs no flaw in your code. It needs a list of email-and-password pairs stolen from some other site, your login form, and the fact that people reuse passwords. This article explains the mechanism at the level a defender needs, shows the exact shape the attack leaves in an authentication log, and works through the controls OWASP and ATT&CK name, ending with a rate limiter you can adapt. It does not tell anyone how to run the attack; it tells you how to see it and how to make it fail.

The mechanism

MITRE ATT&CK catalogues the technique as T1110.004, a sub-technique of Brute Force, and describes it as adversaries using "credentials obtained from breach dumps of unrelated accounts to gain access to target accounts through credential overlap". The word "unrelated" is the whole point: nothing about your site was breached. A different site was, and some fraction of your users used the same password there. The attacker takes the list to your login form and tries each pair. Most fail; a small percentage succeed, because password reuse is common. The ATT&CK page also notes the risk to the attacker: this "carries significant risk due to the likelihood of triggering authentication failures and account lockouts", which is exactly the signal a defender uses.

The lists are large. Have I Been Pwned's entry for the Synthient credential-stuffing corpus, added 6 November 2025 and retrieved for this article on 2026-09-05, describes a threat-intelligence firm aggregating "2 billion unique email addresses disclosed in credential-stuffing lists found across multiple malicious internet sources", with 1.3 billion unique passwords. A list of that size covers a meaningful slice of any consumer user base, which is why the base rate of "some of my users' current passwords are on a list somewhere" is effectively one.

Credential stuffing is not password spraying and not password guessing, though ATT&CK groups all three under T1110. Guessing (T1110.001) tries many passwords against one account. Spraying (T1110.003) tries one common password against many accounts to stay under per-account lockouts. Stuffing brings its own password for each account. The log shapes differ, which matters for detection.

What it looks like in the log

The defender's advantage is that stuffing is noisy. An attacker working a list makes many authentication attempts, and unless they have spread the source addresses widely, those attempts cluster. Three features separate a stuffing run from normal traffic:

  • Failure ratio. Legitimate traffic to a login endpoint succeeds most of the time. A stuffing run fails most of the time, because most stolen pairs do not match. A source with hundreds of attempts and a low success rate is the signature.
  • Distinct accounts per source. Stuffing walks a list of different email addresses. A single source touching dozens of distinct usernames in a short window is not a person.
  • The success that follows failures. The event that matters most is a successful login from a source that has just failed against many other accounts. That is a compromised account, not a lucky user.

ATT&CK's detection guidance for T1110.004 points at "multiple failed authentication attempts using distinct username/password pairs from a single IP address" across services. A few lines of Python over the auth log, grouping failures by source address and by distinct username, is enough to surface that pattern. A query over a web login table looks like this:

-- sources that tried many distinct accounts with a low success rate in the last hour
SELECT source_ip,
       COUNT(*)                                   AS attempts,
       COUNT(DISTINCT username)                   AS distinct_accounts,
       AVG(CASE WHEN result = 'success' THEN 1.0 ELSE 0 END) AS success_rate
FROM login_events
WHERE event_time > now() - interval '1 hour'
GROUP BY source_ip
HAVING COUNT(*) > 50 AND COUNT(DISTINCT username) > 20
ORDER BY attempts DESC;

Sophisticated operators spread the attempts across many source addresses to defeat the per-IP grouping, which is why per-account and per-endpoint counters matter alongside per-source ones, and why the controls below do not rely on IP alone.

The controls OWASP names

The OWASP Credential Stuffing Prevention Cheat Sheet is explicit that the primary defence is multi-factor authentication: it cites Microsoft research that MFA would have prevented "99.9% of account compromises", and recommends it "wherever possible", especially for logins from new devices, unusual locations, or suspicious addresses. MFA works here because the attacker has only the password; the second factor is the gap they cannot cross with a list. The MFA fatigue article on this site covers the one way attackers try to cross it anyway.

Where MFA is not universally deployable, the cheat sheet lists layered alternatives and is candid about the limits of each. CAPTCHAs "can help to identify automated/bot attacks" but "are not perfect" and can be solved. Device and connection fingerprinting help, but device data "is provided by the client, it can potentially be spoofed". IP mitigation should consider classification and geolocation rather than raw volume. Multi-step login and increasing JavaScript complexity raise the attacker's per-attempt cost. The two that attack the root cause are breached-password detection (reject any password present in a breach corpus, the same check NIST SP 800-63B Section 3.1.1.2 requires against "passwords obtained from previous breach corpuses") and user notification (alert on a successful login preceded by failures, and show login history).

Rate limiting, done so it bites the attacker not the user

NIST SP 800-63B Section 3.2.2 requires a verifier to "implement a rate-limiting mechanism that effectively limits the number of failed authentication attempts", with a general ceiling of "no more than 100" consecutive failures. The design goal is to make a list uneconomic to work without locking real users out of their own accounts. A per-account counter with exponential backoff, combined with a per-source counter, does this. The sketch below is deliberately simple and storage-backed so it survives a restart:

import time

def check_allowed(store, account, source_ip, now=None):
    """Return (allowed, retry_after_seconds). Call before verifying the password."""
    now = now or time.time()
    acct_fails = store.recent_failures(f"acct:{account}", window=900)   # 15 min
    ip_fails   = store.recent_failures(f"ip:{source_ip}", window=900)
    # Per-account backoff: 5 free, then double the wait each failure, capped.
    if acct_fails >= 5:
        wait = min(2 ** (acct_fails - 5), 3600)
        if now - store.last_failure(f"acct:{account}") < wait:
            return False, int(wait)
    # Per-source ceiling protects against list-walking from one address.
    if ip_fails >= 100:
        return False, 900
    return True, 0

def record_result(store, account, source_ip, success):
    if success:
        store.clear(f"acct:{account}")
    else:
        store.add_failure(f"acct:{account}")
        store.add_failure(f"ip:{source_ip}")

Two design choices keep it fair. The per-account backoff resets on success, so a user who eventually types the right password is not punished for earlier typos. The per-source ceiling is generous (100) so that a shared corporate NAT does not lock out a whole office, while still stopping a single address from walking a long list. Pair it with breached-password rejection at registration and password change, so the passwords on the list are not valid targets in the first place.

Detection and fix

  • Log every authentication attempt with source, account, result, and timestamp, and compute per-source and per-account failure counts and success-after-failure events. Alert on the last of those first; it is a live compromise.
  • Turn on MFA, prefer phishing-resistant or number-matching forms, and require it for risky logins if you cannot require it everywhere.
  • Reject passwords found in a breach corpus at registration and change, using a k-anonymity range check so no password leaves the browser; the range API returns only hash prefixes, so the check can run without sending the password anywhere.
  • Rate limit per account with backoff and per source with a ceiling near 100, resetting on success.
  • Notify users of a successful login that followed failed attempts, and give them a visible way to end sessions and reset.

The limitation to state plainly: none of this recovers a password that is already on a list. It makes that password useless on your site, by adding a factor the list does not contain, by refusing the password outright, and by making the attempt expensive and visible. The breach that put the password on the list happened somewhere you do not control; what you control is whether it becomes a login on your side.

Everything on this site is written for defenders and for people securing systems they own or are authorised to test. It describes how attacks work so they can be recognised and closed; it is not a guide to accessing anyone else's systems.

Sources

  1. OWASP, "Credential Stuffing Prevention Cheat Sheet"
  2. MITRE ATT&CK, "T1110.004 Brute Force: Credential Stuffing" (last modified 12 May 2026)
  3. Have I Been Pwned, breach entry "Synthient Credential Stuffing Threat Data" (added 6 November 2025; retrieved 2026-09-05)
  4. NIST, SP 800-63B "Digital Identity Guidelines" (Sections 3.1.1.2 blocklist, 3.2.2 rate limiting)