Improper Restriction of Excessive Authentication Attempts

Description

Improper Restriction of Excessive Authentication Attempts occurs when a web application does not limit or improperly limits the number of failed authentication attempts. This allows attackers to conduct brute-force attacks, systematically trying passwords until finding the correct one. Without rate limiting, account lockout, or other protections, attackers can try millions of password combinations, especially with automated tools. This weakness also enables credential stuffing attacks using leaked username/password databases.

Risk

Brute-force attacks are highly effective against weak passwords. With modern hardware, attackers can test billions of hashes offline or thousands of attempts per second online. Credential stuffing using leaked credentials has a success rate of 0.1-2%, meaning millions of compromised accounts from a single database leak. High-profile breaches at companies like Dropbox, LinkedIn, and Yahoo provided massive credential lists that are still used in attacks today.

Solution

Implement account lockout after a threshold of failed attempts (e.g., 5-10 attempts). Use progressive delays that increase with each failed attempt. Implement CAPTCHA after initial failures. Use multi-factor authentication (MFA). Monitor for and block suspicious IP addresses. Implement rate limiting per IP, per account, and globally. Consider using device fingerprinting. Never disclose whether username or password was incorrect. Use bcrypt, Argon2, or scrypt for password hashing to slow offline attacks.

Common Consequences

ImpactDetails
Access ControlScope: Account Takeover

Successful brute-force leads to unauthorized account access.
ConfidentialityScope: Data Exposure

Compromised accounts expose personal and sensitive data.
IntegrityScope: Unauthorized Actions

Attackers can modify data, make purchases, or perform actions as the victim.

Example Code + Solution Code

Vulnerable Code

# VULNERABLE: No rate limiting or lockout
from flask import Flask, request

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']

    user = User.query.filter_by(username=username).first()

    if user and check_password(password, user.password_hash):
        # No tracking of failed attempts
        # Attacker can try unlimited passwords
        session['user_id'] = user.id
        return redirect('/dashboard')

    return 'Invalid credentials', 401  # Also reveals which is wrong
// VULNERABLE: No brute-force protection
@RestController
public class AuthController {

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        User user = userRepository.findByUsername(request.getUsername());

        if (user != null && passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
            String token = jwtService.generateToken(user);
            return ResponseEntity.ok(new AuthResponse(token));
        }

        // No rate limiting, no lockout
        return ResponseEntity.status(401).body("Invalid credentials");
    }
}
// VULNERABLE: No protection against automated attacks
app.post('/login', async (req, res) => {
    const { username, password } = req.body;

    const user = await User.findOne({ username });

    if (user && await bcrypt.compare(password, user.passwordHash)) {
        const token = jwt.sign({ userId: user._id }, secret);
        return res.json({ token });
    }

    // Unlimited attempts allowed
    res.status(401).json({ error: 'Invalid credentials' });
});

Fixed Code

# SAFE: Rate limiting and account lockout
from flask import Flask, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import redis
from datetime import datetime, timedelta

app = Flask(__name__)
limiter = Limiter(app, key_func=get_remote_address)
redis_client = redis.Redis()

MAX_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION = 900  # 15 minutes

def get_failed_attempts(username):
    key = f"failed_login:{username}"
    return int(redis_client.get(key) or 0)

def increment_failed_attempts(username):
    key = f"failed_login:{username}"
    pipe = redis_client.pipeline()
    pipe.incr(key)
    pipe.expire(key, LOCKOUT_DURATION)
    pipe.execute()

def reset_failed_attempts(username):
    redis_client.delete(f"failed_login:{username}")

def is_locked(username):
    return get_failed_attempts(username) >= MAX_FAILED_ATTEMPTS

@app.route('/login', methods=['POST'])
@limiter.limit("10 per minute")  # IP-based rate limiting
def login():
    username = request.form['username']
    password = request.form['password']

    # Check lockout
    if is_locked(username):
        return jsonify({
            'error': 'Account temporarily locked. Try again later.'
        }), 429

    user = User.query.filter_by(username=username).first()

    # Use constant-time comparison to prevent timing attacks
    if user and check_password_hash(user.password_hash, password):
        reset_failed_attempts(username)
        session['user_id'] = user.id
        log_successful_login(username, request.remote_addr)
        return redirect('/dashboard')

    # Always increment, even for non-existent users (prevent enumeration)
    increment_failed_attempts(username)

    # Generic message - don't reveal which was wrong
    remaining = MAX_FAILED_ATTEMPTS - get_failed_attempts(username)
    return jsonify({
        'error': 'Invalid credentials',
        'attempts_remaining': max(0, remaining)
    }), 401
// SAFE: Comprehensive brute-force protection
@RestController
public class SecureAuthController {

    @Autowired
    private LoginAttemptService loginAttemptService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @PostMapping("/login")
    @RateLimited(requests = 10, period = 60)  // Custom annotation
    public ResponseEntity<?> login(
            @RequestBody LoginRequest request,
            HttpServletRequest httpRequest) {

        String ip = getClientIP(httpRequest);
        String username = request.getUsername();

        // Check IP-based blocking
        if (loginAttemptService.isIPBlocked(ip)) {
            return ResponseEntity.status(429)
                .body(new ErrorResponse("Too many requests. Try again later."));
        }

        // Check account lockout
        if (loginAttemptService.isAccountLocked(username)) {
            return ResponseEntity.status(423)
                .body(new ErrorResponse("Account locked. Contact support."));
        }

        User user = userRepository.findByUsername(username).orElse(null);

        // Timing-safe comparison (always do the hash check)
        boolean validPassword = false;
        if (user != null) {
            validPassword = passwordEncoder.matches(
                request.getPassword(),
                user.getPasswordHash()
            );
        } else {
            // Dummy check to prevent timing attacks
            passwordEncoder.matches(request.getPassword(), DUMMY_HASH);
        }

        if (validPassword) {
            loginAttemptService.loginSucceeded(username, ip);

            String token = jwtService.generateToken(user);
            return ResponseEntity.ok(new AuthResponse(token));
        }

        loginAttemptService.loginFailed(username, ip);

        // Add progressive delay
        int attempts = loginAttemptService.getFailedAttempts(username);
        int delay = Math.min(attempts * 1000, 10000);  // Max 10 second delay
        Thread.sleep(delay);

        return ResponseEntity.status(401)
            .body(new ErrorResponse("Invalid credentials"));
    }
}

@Service
public class LoginAttemptService {

    private static final int MAX_ATTEMPTS = 5;
    private static final int LOCKOUT_MINUTES = 15;

    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;

    public void loginFailed(String username, String ip) {
        String userKey = "login:user:" + username;
        String ipKey = "login:ip:" + ip;

        redisTemplate.opsForValue().increment(userKey);
        redisTemplate.expire(userKey, LOCKOUT_MINUTES, TimeUnit.MINUTES);

        redisTemplate.opsForValue().increment(ipKey);
        redisTemplate.expire(ipKey, LOCKOUT_MINUTES, TimeUnit.MINUTES);

        // Log for monitoring
        auditLog.warn("Failed login attempt", Map.of(
            "username", username,
            "ip", ip,
            "attempts", getFailedAttempts(username)
        ));
    }

    public void loginSucceeded(String username, String ip) {
        redisTemplate.delete("login:user:" + username);
        // Don't reset IP counter on success (prevents distributed attacks)
    }

    public boolean isAccountLocked(String username) {
        Integer attempts = redisTemplate.opsForValue().get("login:user:" + username);
        return attempts != null && attempts >= MAX_ATTEMPTS;
    }

    public boolean isIPBlocked(String ip) {
        Integer attempts = redisTemplate.opsForValue().get("login:ip:" + ip);
        return attempts != null && attempts >= MAX_ATTEMPTS * 10;  // Higher threshold for IP
    }

    public int getFailedAttempts(String username) {
        Integer attempts = redisTemplate.opsForValue().get("login:user:" + username);
        return attempts != null ? attempts : 0;
    }
}
// SAFE: Express with rate limiting and lockout
const rateLimit = require('express-rate-limit');
const Redis = require('ioredis');
const redis = new Redis();

const MAX_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 900;

// IP-based rate limiting
const loginLimiter = rateLimit({
    windowMs: 60 * 1000, // 1 minute
    max: 10, // 10 requests per minute per IP
    message: { error: 'Too many requests, please try again later' }
});

async function getFailedAttempts(username) {
    const key = `login:failed:${username}`;
    return parseInt(await redis.get(key)) || 0;
}

async function recordFailedAttempt(username) {
    const key = `login:failed:${username}`;
    await redis.multi()
        .incr(key)
        .expire(key, LOCKOUT_SECONDS)
        .exec();
}

async function resetFailedAttempts(username) {
    await redis.del(`login:failed:${username}`);
}

async function isLocked(username) {
    return await getFailedAttempts(username) >= MAX_ATTEMPTS;
}

app.post('/login', loginLimiter, async (req, res) => {
    const { username, password } = req.body;

    // Check lockout
    if (await isLocked(username)) {
        return res.status(429).json({
            error: 'Account temporarily locked',
            retryAfter: LOCKOUT_SECONDS
        });
    }

    const user = await User.findOne({ username });

    // Always check password hash (timing-safe)
    let valid = false;
    if (user) {
        valid = await bcrypt.compare(password, user.passwordHash);
    } else {
        // Dummy comparison to prevent timing attacks
        await bcrypt.compare(password, '$2b$10$dummyhashvalue');
    }

    if (valid) {
        await resetFailedAttempts(username);

        const token = jwt.sign({ userId: user._id }, secret);
        return res.json({ token });
    }

    await recordFailedAttempt(username);

    const attempts = await getFailedAttempts(username);
    const remaining = Math.max(0, MAX_ATTEMPTS - attempts);

    // Progressive delay
    const delay = Math.min(attempts * 500, 5000);
    await new Promise(resolve => setTimeout(resolve, delay));

    res.status(401).json({
        error: 'Invalid credentials',
        attemptsRemaining: remaining
    });
});

Exploited in the Wild

iCloud Celebrity Photo Leak (2014)

Attackers used brute-force techniques against iCloud's "Find My iPhone" feature, which lacked rate limiting. This allowed credential guessing that led to the leak of private photos from celebrity accounts.

GitHub Brute Force Attack (2013)

GitHub suffered a brute-force attack where attackers used lists of credentials from other breaches. GitHub implemented rate limiting and two-factor authentication in response.

Credential Stuffing Attacks (Ongoing)

Major services including Netflix, Spotify, and various financial institutions regularly face credential stuffing attacks using leaked credential databases, leading to account compromises.


Tools to test/exploit


CVE Examples


References

  1. MITRE. "CWE-307: Improper Restriction of Excessive Authentication Attempts." https://cwe.mitre.org/data/definitions/307.html

  2. OWASP. "Blocking Brute Force Attacks." https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks