Rate limiting authentication endpoints without locking out real users

Credential stuffing attacks hit authentication endpoints with high volume — tens of thousands of requests per minute sourced from botnet infrastructure spread across hundreds of IP addresses. Naive rate limiting blocks the attacker's IPs, and they rotate to fresh ones. Meanwhile, your legitimate users who fat-finger their password twice in quick succession get locked out and call support. The approaches that actually work are more nuanced than a simple request counter.

Why IP-based rate limiting fails

The assumptions behind IP rate limiting break down in real-world conditions:

  • Corporate NAT: an entire enterprise with 500 employees can appear as a single IP address. If one employee makes multiple failed login attempts, you block 499 others.
  • VPN egress nodes: popular VPN services route millions of users through a few hundred IP addresses. Block those IPs and you lose a significant customer segment.
  • IPv6 ranges: attackers on IPv6 have effectively unlimited addresses. A /48 prefix gives 1.2 × 10^24 addresses — rotating IPs is trivial.
  • Residential proxy networks: botnet operators can source IPs from millions of infected home routers, each request from a unique residential IP that looks indistinguishable from a legitimate user.

IP rate limiting is still worth doing as a first layer — it stops the least sophisticated attacks and reduces noise. But it cannot be your only mechanism.

Account-level lockout with exponential backoff

Rate limiting by account identifier (email address) directly addresses credential stuffing because the attacker is targeting specific accounts. The lockout counter increments per failed attempt on a specific account, regardless of source IP.

// Redis-based account lockout
const LOCKOUT_THRESHOLDS = [
  { attempts: 3,  lockoutSeconds: 30     },
  { attempts: 5,  lockoutSeconds: 300    },  // 5 minutes
  { attempts: 8,  lockoutSeconds: 3600   },  // 1 hour
  { attempts: 12, lockoutSeconds: 86400  },  // 24 hours
];

async function recordFailedAttempt(email) {
  const key = `auth:failed:${email.toLowerCase()}`;
  const attempts = await redis.incr(key);

  // Set expiry on first failure
  if (attempts === 1) {
    await redis.expire(key, 86400);  // reset counter after 24h of no failures
  }

  const threshold = LOCKOUT_THRESHOLDS
    .filter(t => attempts >= t.attempts)
    .at(-1);  // take the highest matching threshold

  if (threshold) {
    const lockKey = `auth:locked:${email.toLowerCase()}`;
    await redis.setex(lockKey, threshold.lockoutSeconds, '1');
    return { locked: true, retryAfter: threshold.lockoutSeconds };
  }

  return { locked: false, attempts };
}

async function isAccountLocked(email) {
  const lockKey = `auth:locked:${email.toLowerCase()}`;
  const ttl = await redis.ttl(lockKey);
  if (ttl > 0) return { locked: true, retryAfter: ttl };
  return { locked: false };
}

// On login attempt
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;

  const lockStatus = await isAccountLocked(email);
  if (lockStatus.locked) {
    return res.status(429).json({
      error: 'too_many_attempts',
      retryAfter: lockStatus.retryAfter
    });
  }

  const user = await validateCredentials(email, password);
  if (!user) {
    const result = await recordFailedAttempt(email);
    return res.status(401).json({ error: 'invalid_credentials' });
  }

  // Successful login — clear failure counter
  await redis.del(`auth:failed:${email.toLowerCase()}`);
  // ... issue session
});
Do not reveal lockout status in your error response in a way that confirms the account exists. Return the same HTTP 429 whether the email is in your system or not. An attacker using credential stuffing already knows the email is valid — the lockout response should not confirm this to an enumeration attacker who does not.

Device trust as a lockout bypass

The main criticism of account lockout is that it enables a denial-of-service attack: an adversary who knows a user's email can intentionally trigger lockouts by submitting repeated failed attempts, preventing the real user from logging in. Device trust provides a way out.

A trusted device is one where the user has previously completed a successful login, and a persistent device token has been stored (typically in a browser cookie with HttpOnly; SameSite=Strict; Secure). On subsequent login attempts from that device, the failure threshold is higher, or lockout is bypassed entirely. An attacker performing credential stuffing from a botnet will never have a valid device token.

// Device trust token — stored in HttpOnly cookie
async function issueDeviceToken(userId, userAgent, ip) {
  const token = crypto.randomBytes(32).toString('hex');
  await redis.setex(
    `device:${userId}:${token}`,
    30 * 24 * 3600,  // 30 days
    JSON.stringify({ userAgent, ip, issuedAt: Date.now() })
  );
  return token;
}

async function isDeviceTrusted(userId, deviceToken) {
  if (!deviceToken) return false;
  const data = await redis.get(`device:${userId}:${deviceToken}`);
  return data !== null;
}

// Modified login handler
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const deviceToken = req.cookies.device_token;

  const user = await findUserByEmail(email);

  // If device is trusted, skip lockout check for this account
  const trusted = user ? await isDeviceTrusted(user.id, deviceToken) : false;

  if (!trusted) {
    const lockStatus = await isAccountLocked(email);
    if (lockStatus.locked) {
      return res.status(429).json({ error: 'too_many_attempts' });
    }
  }

  // ... validate credentials, issue device token on success
});

CAPTCHA escalation

CAPTCHA should not appear on every login attempt — that degrades UX for all legitimate users. The right model is progressive escalation: show CAPTCHA only after a threshold of failures, either per-account or per-IP, depending on what triggered the risk signal.

A practical escalation ladder:

  1. 0–2 failures: normal login form, no friction
  2. 3–5 failures from same IP: show CAPTCHA challenge before allowing submission
  3. 5+ failures per account: account lockout with email notification
  4. High IP velocity (>10 req/s): IP-level block, 429 response

For CAPTCHA, Cloudflare Turnstile or hCaptcha are better choices than Google reCAPTCHA v2 for privacy-conscious users. Turnstile in particular has a "managed" mode that runs in the background without any user interaction for low-risk requests, only surfacing a challenge when the risk score warrants it.

The risk engine approach

Bastionary's authentication risk engine scores each login attempt across multiple signals before deciding whether to allow, challenge, or block:

  • Device fingerprint match against prior logins
  • IP reputation (datacenter IP vs residential, known botnet exit node)
  • Geolocation velocity (login from New York, then London 10 minutes later)
  • Time-of-day pattern deviation from the user's historical logins
  • Credential breach status (is this password in the HIBP dataset?)
  • Per-account failure rate over rolling windows (5 min, 1 hour, 24 hours)

Each signal contributes a score. The combined score maps to an action: allow (low risk), challenge with MFA or CAPTCHA (medium risk), or block and require email verification (high risk). This approach avoids binary lockouts and gives legitimate users a path to authenticate even when some signals are elevated.

← Back to blog Try Bastionary free →