The traditional approach to brute force protection is simple: after N failed attempts, lock the account. The problem is that this creates a denial-of-service vector — an adversary who knows a victim's email address can deliberately trigger lockouts, preventing the legitimate user from accessing their account. More subtly, aggressive lockout policies also regularly affect real users who have multiple devices, shared accounts, or just a habit of mistyping passwords. The goal is to stop attackers while letting legitimate users through, and those two requirements are in direct tension.
Exponential backoff vs hard lockout
Hard lockout — disable the account for a fixed period after N failures — is the bluntest instrument. It is easy to implement and clearly stops brute force within N attempts, but it creates the DoS vulnerability described above and generates disproportionate support burden when legitimate users trigger it.
Exponential backoff imposes a mandatory delay between login attempts rather than blocking them entirely. After each failure, the required wait time doubles. This makes brute force impractical (10,000 attempts with 2^N backoff takes years) while allowing legitimate users who mistype their password to retry after a reasonable delay.
// Exponential backoff implementation
const BACKOFF_TABLE = [
{ attempts: 1, delaySeconds: 0 },
{ attempts: 2, delaySeconds: 1 },
{ attempts: 3, delaySeconds: 2 },
{ attempts: 4, delaySeconds: 4 },
{ attempts: 5, delaySeconds: 8 },
{ attempts: 6, delaySeconds: 16 },
{ attempts: 7, delaySeconds: 32 },
{ attempts: 8, delaySeconds: 64 }, // ~1 min
{ attempts: 10, delaySeconds: 256 }, // ~4 min
{ attempts: 12, delaySeconds: 1024 } // ~17 min
];
async function checkBackoffRequirement(email: string): Promise<BackoffResult> {
const key = `auth:backoff:${email.toLowerCase()}`;
const data = await redis.hgetall(key);
if (!data.attempts) return { allowed: true, delaySeconds: 0 };
const attempts = parseInt(data.attempts);
const lastAttemptAt = parseInt(data.last_attempt_at);
const threshold = BACKOFF_TABLE
.filter(t => attempts >= t.attempts)
.at(-1);
if (!threshold || threshold.delaySeconds === 0) {
return { allowed: true, delaySeconds: 0 };
}
const elapsedSeconds = (Date.now() - lastAttemptAt) / 1000;
const remainingDelay = threshold.delaySeconds - elapsedSeconds;
if (remainingDelay > 0) {
return { allowed: false, delaySeconds: remainingDelay };
}
return { allowed: true, delaySeconds: 0 };
}
async function recordLoginAttempt(email: string, success: boolean): Promise<void> {
const key = `auth:backoff:${email.toLowerCase()}`;
if (success) {
await redis.del(key);
return;
}
const pipe = redis.pipeline();
pipe.hincrby(key, 'attempts', 1);
pipe.hset(key, 'last_attempt_at', Date.now().toString());
pipe.expire(key, 86400); // reset after 24h of inactivity
await pipe.exec();
}
Account-level vs IP-level throttling
These protect against different attack patterns and should both be present:
Account-level throttling targets targeted attacks — an attacker who is specifically trying to compromise a particular account. By tracking failures per account, you limit how many guesses per account the attacker can make regardless of how many IPs they rotate through.
IP-level throttling targets spray attacks — an attacker who tests one or two passwords across millions of accounts from a single IP range. By tracking failures per IP, you limit the attack throughput even when no single account is targeted enough to trigger account-level limits.
// Combined account + IP throttling
async function evaluateLoginAttempt(
email: string,
ip: string
): Promise<LoginEvaluation> {
// Account-level backoff
const accountBackoff = await checkBackoffRequirement(email);
if (!accountBackoff.allowed) {
return {
action: 'deny',
reason: 'account_backoff',
retryAfterSeconds: accountBackoff.delaySeconds
};
}
// IP-level rate limit (window-based, not backoff)
const ipFailures = await getWindowCount(
`auth:ip:fail:${ip}`,
{ windowSeconds: 300 } // 5-minute window
);
if (ipFailures > 20) {
return {
action: 'deny',
reason: 'ip_rate_limit',
retryAfterSeconds: 300
};
}
// IP-level challenge escalation
if (ipFailures > 5) {
return { action: 'challenge', challengeType: 'captcha' };
}
// Account-level challenge escalation
const accountAttempts = await getAttemptCount(email);
if (accountAttempts >= 3) {
return { action: 'challenge', challengeType: 'captcha' };
}
return { action: 'allow' };
}
Device trust as an exemption mechanism
Legitimate users will occasionally trigger account-level throttling when they forget their password and try several times. The problem is not that the user is hitting limits — that is working as intended. The problem is that there is no frictionless path for a legitimate user to prove they are legitimate and bypass the limit.
A trusted device token, stored in an HttpOnly cookie from a previous successful login, is that proof. If the user presents a valid device token along with their login attempt, you can reduce or eliminate the backoff requirement. An attacker performing brute force from a botnet will never have a valid device token for the target account.
// Device trust modifies backoff behavior
async function evaluateWithDeviceTrust(
email: string,
ip: string,
deviceToken: string | undefined
): Promise<LoginEvaluation> {
let isTrustedDevice = false;
if (deviceToken) {
const user = await db.users.findByEmail(email);
if (user) {
isTrustedDevice = await isDeviceTrusted(user.id, deviceToken);
}
}
if (isTrustedDevice) {
// Trusted devices get higher limits — only hard-block at very high failure counts
const accountAttempts = await getAttemptCount(email);
if (accountAttempts > 15) {
// Something is very wrong even for a trusted device
return { action: 'deny', reason: 'excessive_failures' };
}
return { action: 'allow', trustedDevice: true };
}
return evaluateLoginAttempt(email, ip);
}
Detecting and responding to distributed attacks
Sophisticated credential stuffing attacks distribute requests across thousands of IP addresses to avoid per-IP limits. Detecting these requires looking at aggregate signals rather than per-IP or per-account signals alone. Track the failure rate across your entire login endpoint over a rolling window. A sudden spike in failure rate (e.g., from 2% to 20% failure rate over 5 minutes) is a strong signal of a distributed attack even if no single IP or account has hit its individual limit.
When you detect a distributed attack pattern, the escalation options are: require CAPTCHA for all login attempts globally (high friction, effective), block logins from datacenter IP ranges (most bots use cloud or VPN infrastructure, not residential IPs), or shift to an out-of-band challenge (email a login link rather than accepting a password) until the attack subsides.