Skip to main content
Back to blog
April 28, 2026·Updated April 28, 2026|8 min read|Antoine Duno

Brute Force Attack Prevention: Rate Limiting, Lockout & CAPTCHA

Brute force attacks on login forms, APIs, and password reset flows are automated and relentless. These are the controls that stop them.

ZeriFlow Team

1,484 words

Brute Force Attack Prevention: Rate Limiting, Lockout & CAPTCHA

Brute force attack prevention is a core requirement for any web application that handles authentication. Automated tools can test hundreds of thousands of credential combinations per hour. Without proper defenses, a weak password or a leaked username list is all an attacker needs to gain access.

Check your site's security configuration: Free ZeriFlow scan in 60 seconds →

Types of Brute Force Attacks

Understanding the attack type determines which defense is most effective.

Credential stuffing: attackers use username/password pairs from previous data breaches. Because people reuse passwords, stuffing databases often achieve 1-5% success rates even against sites that were never breached.

Password spraying: instead of hammering one account, the attacker tries one common password (e.g., Summer2024!) against thousands of accounts. This avoids per-account lockouts while exploiting the statistical likelihood that some users have weak passwords.

Dictionary attacks: systematic testing of common passwords, dictionary words, and common transformations against a specific account.

Reverse brute force: the attacker fixes the password (e.g., password123) and tests it against millions of accounts — the inverse of a dictionary attack.

Token brute forcing: guessing short session tokens, password reset tokens, email verification codes, or OTP codes with insufficient entropy.

Rate Limiting: First Line of Defense

Rate limiting restricts how many requests a client can make in a time window. It should be applied at multiple layers:

Layer 1 — Reverse proxy / CDN (Nginx, Cloudflare):

nginx
# nginx — rate limit login endpoint
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /api/auth/login {
    limit_req zone=login burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

Layer 2 — Application layer with Redis-backed token bucket:

javascript
const redis = require('redis');
const client = redis.createClient();

async function rateLimitCheck(key, limit, windowSeconds) {
  const current = await client.incr(key);
  if (current === 1) {
    await client.expire(key, windowSeconds);
  }
  return current <= limit;
}

// Login endpoint rate limiting — 5 attempts per 15 minutes per IP
app.post('/api/login', async (req, res) => {
  const ipKey = `rl:login:ip:${req.ip}`;
  const emailKey = `rl:login:email:${req.body.email?.toLowerCase()}`;

  const ipAllowed = await rateLimitCheck(ipKey, 20, 900);     // 20/15min per IP
  const emailAllowed = await rateLimitCheck(emailKey, 5, 900); // 5/15min per email

  if (!ipAllowed || !emailAllowed) {
    return res.status(429).json({
      error: 'Too many login attempts. Please try again in 15 minutes.',
      retryAfter: 900
    });
  }

  // proceed with authentication
});
python
import redis
import time
from functools import wraps
from flask import request, jsonify

r = redis.Redis()

def rate_limit(key_prefix: str, limit: int, window: int):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            key = f"{key_prefix}:{request.remote_addr}"
            pipe = r.pipeline()
            pipe.incr(key)
            pipe.expire(key, window)
            result = pipe.execute()
            count = result[0]
            if count > limit:
                return jsonify(error="Rate limit exceeded"), 429
            return f(*args, **kwargs)
        return wrapped
    return decorator

@app.route('/login', methods=['POST'])
@rate_limit('login', limit=5, window=900)
def login():
    pass

Rate limit not just /login but also /password-reset, /email-verify, /api/token, and any other endpoint that accepts credentials or generates secrets.

Account Lockout: Balancing Security and Usability

Account lockout temporarily disables an account after too many failed attempts. The challenge is that aggressive lockout enables denial-of-service attacks: an attacker who knows your users' email addresses can lock them all out.

Best practice configuration: - Lock after 10 consecutive failures (not 3-5). - Lock duration: 15-30 minutes (temporary), not permanent. - Send a notification email to the user when their account is locked. - Provide a self-service unlock via email link. - Reset the counter on successful login. - Track failed attempts per account, not per IP (IP changes too easily).

javascript
async function recordFailedLogin(userId) {
  const key = `lockout:${userId}`;
  const attempts = await redis.incr(key);
  
  if (attempts === 1) {
    await redis.expire(key, 1800); // 30-minute window
  }
  
  if (attempts >= 10) {
    await db.users.update(userId, {
      locked_until: new Date(Date.now() + 30 * 60 * 1000),
      locked_reason: 'too_many_failed_attempts'
    });
    await sendLockoutNotificationEmail(userId);
  }
  
  return attempts;
}

async function checkAccountLocked(userId) {
  const user = await db.users.findById(userId);
  if (!user.locked_until) return false;
  if (new Date() > user.locked_until) {
    // Lock expired — clear it
    await db.users.update(userId, { locked_until: null });
    return false;
  }
  return true;
}

CAPTCHA: Blocking Automation

CAPTCHA challenges prove the requester is human. Deploy CAPTCHA after the first failed login attempt rather than on every login — this avoids friction for legitimate users while blocking automated tools.

Google reCAPTCHA v3 (score-based, invisible):

javascript
// Frontend — submit token with form
const recaptchaToken = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'login' });

// Backend — verify score
async function verifyRecaptcha(token) {
  const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `secret=${process.env.RECAPTCHA_SECRET}&response=${token}`
  });
  const data = await response.json();
  return data.success && data.score >= 0.5; // 0.9 = very likely human
}

hCaptcha (privacy-focused alternative to reCAPTCHA):

javascript
// Same API shape, different endpoint
const response = await fetch('https://hcaptcha.com/siteverify', {
  method: 'POST',
  body: `secret=${process.env.HCAPTCHA_SECRET}&response=${token}`
});

For maximum bot blocking with minimal user friction, combine CAPTCHA with rate limiting: add the CAPTCHA challenge after 2 failed attempts per IP, before applying account lockout.

IP Throttling and Geo-Blocking

For credential stuffing at scale, IP-level controls are essential:

javascript
// Exponential backoff by IP
async function getIPDelay(ip) {
  const attempts = await redis.get(`attempts:${ip}`) || 0;
  if (attempts < 5) return 0;
  return Math.min(Math.pow(2, attempts - 5) * 1000, 60000); // max 60s
}

// Suspicious IP detection
async function isSuspiciousIP(ip) {
  // Check against reputation APIs
  const abuseScore = await checkAbuseIPDB(ip);
  if (abuseScore > 50) return true;

  // Check if IP is a known proxy/VPN/Tor exit node
  const isTor = await checkTorExitList(ip);
  return isTor;
}

ZeriFlow checks that your application's HTTP security headers are properly configured — including headers that help your WAF and CDN identify and block malicious traffic patterns. Proper X-Frame-Options, Content-Security-Policy, and CORS headers all contribute to reducing attack surface.

Protecting Password Reset and OTP Flows

Password reset flows are often overlooked. A 6-digit numeric reset code has only 1,000,000 possible values — easily brute-forced within 15 minutes at 1,000 requests per second without rate limiting.

javascript
// Secure password reset token generation
const crypto = require('crypto');

function generateResetToken() {
  return crypto.randomBytes(32).toString('hex'); // 256-bit token
}

// Store with expiration and rate limit requests
async function initiatePasswordReset(email) {
  const rateLimitKey = `reset:${email}`;
  const attempts = await redis.incr(rateLimitKey);
  if (attempts === 1) await redis.expire(rateLimitKey, 3600);
  if (attempts > 3) {
    return; // silently ignore — don't reveal if email exists
  }

  const token = generateResetToken();
  const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

  await db.resetTokens.create({ email, token: hash(token), expires });
  await sendResetEmail(email, token);
}

FAQ

### Q: What is the best rate limiting strategy for login endpoints? A: Use a combination of per-IP and per-account rate limits. Per-IP catches spraying and stuffing attacks. Per-account catches targeted dictionary attacks from distributed IPs. A sliding window of 5 attempts per account per 15 minutes is a reasonable starting point.

### Q: Does account lockout stop credential stuffing? A: Partially. Credential stuffing typically uses valid credentials, so it succeeds on the first attempt and does not trigger lockout. Rate limiting per IP and CAPTCHA are more effective against stuffing. HaveIBeenPwned integration at registration helps reduce the credential pool attackers can exploit.

### Q: Should I tell users why their login failed? A: Return a generic message ("Invalid email or password") rather than revealing whether the email exists. Per-account error messages let attackers enumerate valid accounts, which accelerates targeted attacks.

### Q: How do I implement rate limiting across multiple servers? A: Use a shared backend store like Redis. In-memory rate limiting only works on single-server deployments and is bypassed when requests distribute across multiple instances. Redis with a sliding window counter is the standard solution.

### Q: Is CAPTCHA enough on its own? A: No. Sophisticated attackers use CAPTCHA-solving farms and ML-based solvers. CAPTCHA raises the cost of automation but does not eliminate it. Use CAPTCHA in combination with rate limiting, lockout, and anomaly detection (unusual login locations, device fingerprinting).


Conclusion

Brute force prevention requires a layered approach: rate limiting at the proxy and application layers, progressive account lockout, CAPTCHA after failed attempts, and cryptographically strong reset tokens. No single control is sufficient — attackers adapt to any single defense. Implement all layers, monitor for anomalies, and review your configuration regularly.

Run a free ZeriFlow scan → — no credit card required.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading