How to Secure wp-login Without Installing a Security Plugin

How to Secure wp-login Without Installing a Security Plugin

Written by

in

You can harden wp-login.php with things WordPress and your server already give you: a long unique password per admin, XML-RPC switched off, login attempts rate-limited at the CDN or firewall, and the public user list closed. A security plugin mostly packages those same steps and adds alerting and file scanning on top. Here’s the order I do it in, with the settings and the one snippet I actually use.

The short version

  • Password first. A generated 20-character-plus password on every admin account, stored in a password manager, removes most of the value of an automated login attack.
  • Take away what you don’t use: XML-RPC, incoming pingbacks, and the public list of usernames.
  • Rate-limit wp-login.php at the edge — CDN, WAF, or host firewall — not inside PHP.
  • Add a real second factor. WordPress core has none, and I won’t pretend otherwise; that step needs either a small plugin or an identity provider in front of the login.
  • Put monitoring where you’ll see it. A blocked attack you never hear about is fine. A successful one you never hear about is not.

1. Fix the accounts before you touch the login page

Login hardening does nothing if there are five admin accounts and nobody remembers what two are for. Open Users → All Users, filter by Administrator, and be honest about each one:

  • Delete or downgrade anything you don’t recognise. A dormant admin account is a back door with a password you never changed.
  • One admin per human. Writers and editors don’t need Administrator — Editor can publish, and it can’t install plugins.
  • Change any password that’s a word, a date, or used on another site. Let the password manager generate it; you should never need it in your head.
  • Check the admin email while you’re in there — password resets land in that inbox, so it has to be one you read.

If you have WP-CLI, wp user list --role=administrator --fields=ID,user_login,user_email,user_registered gives you the same list in one line, which is faster on a site you inherited.

2. Turn off the parts of the API you’re not using

WordPress exposes more than the login form. Three things get abused over and over:

  • XML-RPC. One request can carry hundreds of username/password pairs — the favourite amplifier for guessing — and it’s how incoming pingbacks arrive. If you don’t publish from a desktop app, you don’t need it.
  • The REST user list. /wp-json/wp/v2/users answers with your authors’ usernames, which turns “guess a password” into “guess a password for a name I now know”.
  • Author archives by ID. ?author=1 redirects to the author’s archive, and the slug in the URL is the login name.

Drop this into login-hardening.php inside wp-content/mu-plugins/, creating the folder if it isn’t there. Must-use plugins load automatically — no activation, and nobody can switch them off from the admin.

<?php
/*
Plugin Name: Login Hardening
Description: Disables XML-RPC, restricts the users endpoint, stops author-ID probing.
*/

// Incoming XML-RPC (and therefore pingbacks) off.
add_filter( 'xmlrpc_enabled', '__return_false' );

// /wp-json/wp/v2/users only answers for people who can list users.
add_filter( 'rest_endpoints', function ( $endpoints ) {
    $routes = array( '/wp/v2/users', '/wp/v2/users/(?P<id>[\d]+)' );
    foreach ( $routes as $route ) {
        if ( empty( $endpoints[ $route ] ) ) {
            continue;
        }
        foreach ( $endpoints[ $route ] as $i => $handler ) {
            $endpoints[ $route ][ $i ]['permission_callback'] = function () {
                return current_user_can( 'list_users' );
            };
        }
    }
    return $endpoints;
} );

// ?author=1 stops redirecting to a login name.
add_action( 'template_redirect', function () {
    if ( is_author() && isset( $_GET['author'] ) ) {
        wp_safe_redirect( home_url(), 301 );
        exit;
    }
} );

This restricts the users endpoint rather than deleting it, because the block editor calls that route to render the author dropdown. If something else breaks afterwards — a newsletter plugin that lists authors, a mobile app — remove the specific block, not the whole file.

3. Rate-limit at the edge, not in PHP

A limiter written in PHP still boots WordPress, loads plugins, and opens a database connection for every attempt — exactly the work an attacker wants. Push the limit out to whatever sits in front of the site:

  • Behind a CDN (Cloudflare and similar): a rate-limiting rule where the URI path equals /wp-login.php, a handful of requests per minute per IP, action block for an hour — or challenge, if you’d rather not hard-block shared IPs.
  • No CDN: ask your host whether login rate limiting and fail2ban are already on. Most cPanel and Plesk stacks can block an IP after repeated failed logins, and the IP Blocker tool handles repeat offenders by hand.
  • Apache with mod_security: a rule scoped to POST requests on wp-login.php costs nothing and never touches your theme.

Keep the threshold generous: five attempts a minute per IP stops a script and lets a person who mistyped three times through. And never block /wp-admin/ as a whole — admin-ajax.php lives there.

4. Hiding the login path is worth doing, but it isn’t a control

Moving wp-login.php to another URL cuts log noise, because generic bots only try the default path. It stops nobody who has decided to target you: they read your homepage and find the new link. Treat it as tidying, not security.

The version that is security: put a real authentication gate in front of the login. Cloudflare Access on /wp-login.php and /wp-admin/ means only the identities you list can see the form at all, and the WordPress password becomes the second lock rather than the only one. Without a CDN, the hosting panel’s “Password Protect Directories” does the same with one shared password — fine solo, wrong for a team.

Whichever you choose, test it the boring way: log out, open a private window, get back in, then confirm comments and contact forms still post.

5. What a plugin adds on top

I do run a security plugin on client sites, for concrete reasons: core has no two-factor login, no record of who tried to log in, and no way to tell you a file changed. Here’s how I split what needs code from what can be settings.

Layer What it stops Where you set it
Long unique password A reused password leaking in from another breach Password manager + Users screen
Login rate limit Automated guessing loops CDN/WAF or host firewall
XML-RPC off Amplified guessing and pingback abuse mu-plugin, plus a server rule if you have one
User list closed Harvesting logins to aim the attack mu-plugin
Edge access gate Everyone who isn’t on your identity list Cloudflare Access or host directory password
Two-factor login Someone using a password they already stole Plugin or identity provider
Login log + file check Being the last to know you were breached Plugin, or your host’s monitoring

If you install one, install exactly one: two firewalls fighting over the same request is how a site locks its owner out of admin-ajax.php. And a plugin is code — update it, remove it the day you stop using it.

6. Watch the one log line that tells you it happened

Every login attempt reaches your web server before WordPress sees it, so the access log is the honest record. Look for POST requests to wp-login.php: a wall of them from one address is a guessing run, and a 200 in that column instead of a 302 means somebody got in. Compare yesterday’s count with today’s.

Whatever you set up, make sure a change leaves a trace you’ll read — a rate-limit block notification, a host alert, or a weekly glance at the log. Catching it early is the difference between changing a password and working through a full infection check. If you’re already past that point, the steps are in my write-up of cleaning a hacked site — and get a working copy before you edit files, per what to back up and how to test a restore.

Checklist

  • Administrator list reviewed; unused admins deleted, writers moved to Editor
  • Every admin password generated by a password manager, none reused
  • Admin email is one you control and read
  • login-hardening.php in mu-plugins; site and block editor still load
  • Rate-limit rule on /wp-login.php at the CDN or host firewall
  • Second factor added, or an access gate in front of /wp-admin/
  • Login tested in a private window, plus one form that uses admin-ajax.php
  • One place where failed logins and file changes will actually reach you

Do the first three today — twenty minutes, start to finish: change the admin password, add the rate-limit rule on /wp-login.php, and drop the mu-plugin in. Then log out, log back in, publish a test draft, delete it. If that works, the biggest hole on the site is closed without a new plugin.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *