Protect the WordPress login page from brute-force attacks

The WordPress login page at /wp-login.php is one of the most frequently targeted URLs on the internet, bombarded daily by automated bots attempting to guess administrator passwords through brute-force and credential-stuffing attacks. A compromised admin account gives an attacker full control of your site, from injecting malware to sending spam. Fortunately, most brute-force attacks can be stopped with a combination of simple hardening steps that require no plugins and minimal technical knowledge. Limiting login attempts is the single most effective measure: after three to five failed attempts, block that IP for an increasing delay. Moving the login URL from the default /wp-login.php to a custom path — as described in the login URL guide — eliminates most automated attacks entirely because bots look for the default path. Requiring a strong password policy for all user accounts is enforced through the user_profile_update_errors hook. Two-factor authentication (2FA) adds a second verification layer so that even a correctly guessed password is not enough. Combining these measures with the HTTP security headers and xmlrpc.php blocking creates a defence-in-depth strategy. The code below implements rate-limiting using WordPress transients, with no external dependencies required.

Problem: Your WordPress login page receives constant brute-force attempts and you want to rate-limit failed logins without installing a full security plugin.

Solution: Add the following code to your functions.php file:

/**
 * Rate-limit login attempts: block IP for 15 minutes after 5 failures.
 */
define( 'HELLOADMIN_LOGIN_MAX_ATTEMPTS', 5 );
define( 'HELLOADMIN_LOGIN_LOCKOUT_SECONDS', 900 ); // 15 minutes

add_filter( 'wp_authenticate_user', 'helloadmin_check_login_attempts', 10, 2 );
function helloadmin_check_login_attempts( $user, $password ) {
    $ip  = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
    $key = 'login_attempts_' . md5( $ip );

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

add_action( 'wp_login_failed', 'helloadmin_record_failed_attempt' );
function helloadmin_record_failed_attempt( $username ) {
    $ip  = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
    $key = 'login_attempts_' . md5( $ip );

    $attempts = (int) get_transient( $key );
    set_transient( $key, $attempts + 1, HELLOADMIN_LOGIN_LOCKOUT_SECONDS );
}

add_action( 'wp_login', 'helloadmin_clear_failed_attempts', 10, 2 );
function helloadmin_clear_failed_attempts( $user_login, $user ) {
    $ip  = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
    delete_transient( 'login_attempts_' . md5( $ip ) );
}

NOTE: Transient-based rate limiting is reset whenever the object cache is flushed or WordPress cron clears expired transients, so determined attackers with patience can work around it by waiting for the lockout to expire. For production sites, complement this with a server-level firewall rule (Fail2Ban, Cloudflare WAF, or your hosting provider’s DDoS protection) that permanently bans IPs after a configurable number of lockouts. Also log the blocked IPs to a meta key so you can identify repeat offenders.