# Account lockout after repeated failed login attempts

- **Pattern:** `ab-002375` (`security-hardening.auth-session.account-lockout`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002375
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002375)

## Why it matters

Without account lockout, a login endpoint is open to unlimited credential stuffing — automated attacks that replay leaked username/password lists from other breaches. CWE-307 (Improper Restriction of Excessive Authentication Attempts) and OWASP A07 (Identification & Authentication Failures) specifically call this out. NIST 800-53 AC-7 mandates account lockout after a defined number of consecutive failures. Distributed attacks from thousands of IPs circumvent IP-level rate limiting; only per-account lockout stops an attacker who has a valid email list and a leaked credential database.

## Severity rationale

High because an unlocked login endpoint allows automated credential stuffing to compromise accounts at scale, limited only by the attacker's bandwidth.

## Remediation

Track failed attempts in the database and lock the account after 5 consecutive failures within 15 minutes:

```ts
const MAX_ATTEMPTS = 5
const LOCKOUT_MS = 15 * 60 * 1000

if (user.lockedUntil && user.lockedUntil > new Date()) {
  return { error: 'Account temporarily locked. Try again later.' }
}

if (!isPasswordValid) {
  const count = (user.failedLoginAttempts ?? 0) + 1
  await db.user.update({
    where: { id: user.id },
    data: {
      failedLoginAttempts: count,
      lockedUntil: count >= MAX_ATTEMPTS ? new Date(Date.now() + LOCKOUT_MS) : null,
    },
  })
}
```

Reset `failedLoginAttempts` to 0 on successful login. Store the counter in the database, not in-memory — in-memory counters reset on deployment.

## Detection

- **ID:** `account-lockout`
- **Severity:** `high`
- **What to look for:** Count all login and authentication endpoints. For each, check the login endpoint for brute-force protection. Look for failed attempt counters in the database or cache, lockout logic that temporarily blocks logins after N failures, and a documented reset mechanism. Rate limiting at the IP level (covered separately) is a complement but not a substitute — account-level lockout specifically prevents distributed brute force attacks.
- **Pass criteria:** After 5–10 consecutive failed login attempts, the account is temporarily locked or requires additional verification (CAPTCHA, email confirmation). The lockout duration is documented. There is a mechanism to unlock (time-based expiry or admin override) after at least 5 failed attempts within a 15-minute window. Report the count: "X login endpoints found, all Y enforce account lockout."
- **Fail criteria:** No failed attempt tracking; the login endpoint accepts unlimited attempts without any progressive delay or lockout.
- **Skip (N/A) when:** The application has no password-based login (OAuth-only, magic link only), or uses a managed auth provider with built-in lockout policies.
- **Detail on fail:** `"Login endpoint accepts unlimited failed attempts with no lockout or progressive delay — credential stuffing attacks are unmitigated"` or `"Failed attempts tracked in-memory only — counter resets on server restart and doesn't persist across instances"`
- **Remediation:** Implement account lockout using your database or a distributed cache:

  ```ts
  const MAX_ATTEMPTS = 5
  const LOCKOUT_DURATION_MINUTES = 15

  async function attemptLogin(email: string, password: string) {
    const user = await db.user.findUnique({ where: { email } })

    if (!user) {
      // Constant-time response to prevent user enumeration
      await bcrypt.compare(password, '$2b$12$invalidhashfortimingatk')
      return { success: false, error: 'Invalid credentials' }
    }

    // Check lockout
    if (user.lockedUntil && user.lockedUntil > new Date()) {
      return { success: false, error: 'Account temporarily locked. Try again later.' }
    }

    const isValid = await bcrypt.compare(password, user.passwordHash)

    if (!isValid) {
      const newCount = (user.failedLoginAttempts ?? 0) + 1
      await db.user.update({
        where: { id: user.id },
        data: {
          failedLoginAttempts: newCount,
          lockedUntil: newCount >= MAX_ATTEMPTS
            ? new Date(Date.now() + LOCKOUT_DURATION_MINUTES * 60 * 1000)
            : null,
        },
      })
      return { success: false, error: 'Invalid credentials' }
    }

    // Reset on successful login
    await db.user.update({
      where: { id: user.id },
      data: { failedLoginAttempts: 0, lockedUntil: null },
    })
    return { success: true, user }
  }
  ```

## External references

- cwe CWE-307
- owasp A07
- nist AC-7

Taxons: access-control

HTML version: https://auditbuffet.com/patterns/ab-002375
