Prevent brute force attacks on WordPress login with rate limiting

WordPress login pages are a constant target for automated brute force attacks that try thousands of username and password combinations per minute. Without any rate limiting, WordPress will attempt to authenticate every single request, consuming PHP-FPM workers, hammering the database with user lookup queries, and potentially succeeding if a user has a weak password. The most effective defence layers are: blocking known bad IP ranges at the web server level before PHP even starts, rate limiting login attempts per IP with a PHP-based lockout system using WordPress transients, adding a CAPTCHA or proof-of-work challenge to the login form, and restricting access to wp-login.php by IP address for sites where only a fixed set of IPs need admin access. The transient-based lockout approach works without a plugin: on every failed login the wp_login_failed hook fires, you increment a transient counter keyed to the client IP, and if the count exceeds a threshold you redirect the login attempt to a 403 page. The authenticate filter is the hook to check the lockout status — it runs before WordPress validates the credentials, so you can abort the attempt early without a database query. Combining this with an .htaccess rule that restricts wp-login.php to trusted IPs provides defence in depth — bots never reach PHP at all. The file permissions guide and the SSL guide cover the other hardening layers that should accompany login protection.

Problem: WordPress login page receives hundreds of automated brute force requests per minute with no lockout mechanism, risking account compromise and server overload.

Solution: Use WordPress transients to count failed attempts per IP and block further attempts after a threshold, combined with an .htaccess IP allowlist:

// Track failed login attempts and lock out after 5 failures in 15 minutes
add_action( 'wp_login_failed', 'ha_track_failed_login' );

function ha_track_failed_login( $username ) {
    $ip  = ha_get_client_ip();
    $key = 'ha_login_fail_' . md5( $ip );

    $attempts = (int) get_transient( $key );
    set_transient( $key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
}

// Block the login attempt before credentials are checked
add_filter( 'authenticate', 'ha_check_login_lockout', 1, 3 );

function ha_check_login_lockout( $user, $username, $password ) {
    $ip  = ha_get_client_ip();
    $key = 'ha_login_fail_' . md5( $ip );

    if ( (int) get_transient( $key ) >= 5 ) {
        return new WP_Error(
            'too_many_attempts',
            sprintf(
                __( 'Too many failed login attempts. Please try again in %d minutes.', 'ha' ),
                15
            )
        );
    }
    return $user;
}

// Safe client IP helper — avoids spoofable headers
function ha_get_client_ip() {
    // Only trust REMOTE_ADDR; proxy headers are spoofable without server-side validation
    return isset( $_SERVER['REMOTE_ADDR'] )
        ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
        : '0.0.0.0';
}

# .htaccess — restrict wp-login.php to specific IPs only
# Replace 1.2.3.4 and 5.6.7.8 with your actual admin IP addresses

<Files "wp-login.php">
    Require ip 1.2.3.4
    Require ip 5.6.7.8
</Files>

NOTE: The REMOTE_ADDR-only IP detection used here is intentional — headers like HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP can be spoofed by attackers to bypass IP-based lockouts by submitting arbitrary IP addresses. If your server sits behind a trusted proxy or load balancer (such as Cloudflare or Nginx), configure the proxy to overwrite REMOTE_ADDR with the real client IP at the infrastructure level rather than reading the forwarded headers in PHP. The .htaccess IP restriction is the strongest protection but requires a static IP — if you access the admin from a dynamic IP, use the PHP rate limiting alone and consider adding two-factor authentication via the authenticate filter instead.