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.
High because an unlocked login endpoint allows automated credential stuffing to compromise accounts at scale, limited only by the attacker's bandwidth.
Track failed attempts in the database and lock the account after 5 consecutive failures within 15 minutes:
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.
ID: security-hardening.auth-session.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:
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 }
}