IP-based rate limiting alone does not stop a targeted attack on a specific account from a botnet that uses one password attempt per IP address. CWE-307 and NIST 800-63B §5.2.2 require per-account throttling in addition to per-IP limits. PCI-DSS Req-8.3.4 requires temporary lockout after no more than 10 consecutive failures. OWASP A07 lists credential stuffing and brute-force susceptibility as named failure modes. Supabase Auth provides endpoint rate limiting but does not implement configurable per-account lockout — custom lockout logic is required unless you use Clerk or Auth0 with Attack Protection enabled.
High because per-account lockout is the only control that stops a distributed brute-force attack targeting a single user account from many IP addresses simultaneously.
Track failed attempts on the user record and lock the account temporarily after exceeding the threshold:
async function recordFailedAttempt(userId: string) {
const user = await db.user.update({
where: { id: userId },
data: {
failedLoginAttempts: { increment: 1 },
lastFailedLoginAt: new Date()
}
})
if (user.failedLoginAttempts >= 5) {
await db.user.update({
where: { id: userId },
data: { lockedUntil: new Date(Date.now() + 15 * 60 * 1000) }
})
await sendLockoutEmail(user.email)
}
}
Check lockedUntil at the start of the login handler, before the password comparison. Reset the counter to zero on a successful login.
ID: saas-authentication.auth-flow.account-lockout
Severity: high
What to look for: Check the login flow for account lockout logic — tracking failed attempts per account and temporarily locking the account (not just rate limiting by IP) after a threshold is exceeded. Look for failed attempt counters on the user record or in a separate table. Check if the user is notified of lockout via email. Managed providers (Clerk, Auth0) include this; verify it's enabled in their configuration. Count every lockout trigger condition and enumerate each (failed password attempts, failed OTP, suspicious IP). Report the lockout threshold and duration for each.
Pass criteria: After N consecutive failed login attempts (typically 5-10), the account is temporarily locked for a defined period. The lockout is per-account, not just per-IP. Lockout events trigger a notification email to the account owner (recommended, not required for pass). Threshold: no more than 5 consecutive failed attempts before temporary lockout.
Fail criteria: No per-account lockout logic — only IP-based rate limiting (if even that), which doesn't prevent targeted attacks on specific accounts from distributed IPs.
Skip (N/A) when: Fully managed auth provider with lockout enabled (Clerk, Auth0 with Attack Protection, Firebase Auth with reCAPTCHA). Signal: no custom login route, provider handles auth. Note: Supabase Auth provides built-in rate limiting on auth endpoints but does NOT provide configurable per-account lockout policies. Projects using Supabase as their sole auth provider should FAIL this check unless supplemental lockout controls (e.g., custom middleware that tracks failed attempts per account and temporarily blocks after N failures) are implemented.
Detail on fail: "Login handler tracks no failed attempt count per account — an attacker with distributed IPs can brute-force any specific account indefinitely".
Remediation: IP rate limiting alone is not enough — a botnet can try one password from each IP. Per-account lockout catches this:
async function recordFailedAttempt(userId: string) {
const user = await db.user.update({
where: { id: userId },
data: {
failedLoginAttempts: { increment: 1 },
lastFailedLoginAt: new Date()
}
})
if (user.failedLoginAttempts >= 5) {
await db.user.update({
where: { id: userId },
data: { lockedUntil: new Date(Date.now() + 15 * 60 * 1000) }
})
await sendLockoutEmail(user.email)
}
}
Always check lockedUntil at the start of the login handler before verifying the password.