Theory
What you can do is, only allow access to your wp-login.php
, when a special token is available and is validated to be true, otherwise you will die
on the page with a status code of 403
or 404
(whatever you prefer).
To generate the token, you can set up another secret route on your site, generate the token here and redirect with that information to the wp-login.php
.
Things you need to get this concept working are:
- Registering a custom route with
add_rewrite_rule()
- Whitelisting the target query var of your route in the
query_vars
filter
- Set up a callback, which checks for the query var and eventually generates the token and redirects using
wp_redirect()
- Hook into the
wp-login.php
to check if the token is there, it is valid, and it is, show the login page, otherwise die
(Also, you have to make sure, the current page is the login page, since there is not a direct hook)
Tokens
Your token can either be fixed, or it could be dynamic and refreshes itself on every page view (more secure for sure).
To generate your tokens, you can use WordPress wp_create_nonce()
and wp_verify_nonce()
functions.
To pass the tokens from your route to wp-login.php
page during the redirect, you could use one of these methods:
- Attaching the token as GET parameter
- Attaching the token as POST parameter
- Attaching the token as HTTP header, for example
Authorization
- Attaching the token as HTTP cookie
- Storing it as WordPress transient in the database
- Storing inside a PHP session
Some listed options should be preferred above others, for example using PHP sessions inside WordPress is probably not the highest security standard: https://techgirlkb.guru/2017/08/wordpress-doesnt-use-php-sessions-neither/
Code Example
In this example, I have coded a combination of a token generation with WordPress nonces and a redirect with the attached token as GET parameter.
<?php
declare(strict_types=1);
use Devidw\WordPress\Helper\Helper;
class HardenLogin
{
/**
* Constructor.
*/
public function __construct()
{
add_action(
hook_name: 'init',
callback: [$this, 'addCustomLoginPageUrl'],
);
add_filter(
hook_name: 'query_vars',
callback: [$this, 'addCustomLoginPageUrlQueryVar'],
);
add_action(
hook_name: 'template_redirect',
callback: [$this, 'addCustomLoginPageRoute'],
);
add_action(
hook_name: 'init',
callback: [$this, 'replaceDefaultLoginPage'],
priority: PHP_INT_MAX,
);
}
/**
* Get the custom login page slug.
*/
public function getCustomLoginPageSlug(): string
{
return 'my-super-secret-login-page';
}
/**
* Add custom login page route.
*/
public function addCustomLoginPageUrl(): void
{
add_rewrite_rule(
regex: '^' . $this->getCustomLoginPageSlug() . '/?$',
query: 'index.php?my_hard_login=1',
after: 'top',
);
}
/**
* Add custom login page query var.
*/
function addCustomLoginPageUrlQueryVar(array $vars): array
{
$vars[] = 'my_hard_login';
return $vars;
}
/**
* Add a custom login page.
*/
public function addCustomLoginPageRoute(): void
{
if (get_query_var('my_hard_login', false) === false) {
return;
}
wp_redirect(
location: add_query_arg(
'my_hard_login_nonce',
wp_create_nonce('my_hard_login_nonce'),
wp_login_url(),
),
);
die;
}
/**
* Replace the default login page.
*/
public function replaceDefaultLoginPage(): void
{
if (
!Helper::isLoginPage() or
!empty($_GET['action']) and $_GET['action'] === 'logout' and !empty($_GET['_wpnonce']) and wp_verify_nonce($_GET['_wpnonce'], 'log-out') === 1 or
!empty($_GET['dw_hard_login_nonce']) and wp_verify_nonce($_GET['dw_hard_login_nonce'], 'dw_hard_login_nonce') === 1
) {
return;
}
header('HTTP/1.0 404 Not Found');
@include_once get_query_template('404');
die;
}
}
To determine, if we are on the login page, you can check out this answer: https://wordpress.stackexchange.com/a/237285/218274.
It basically is what Helper::isLoginPage()
is doing: https://github.com/devidw/wp-helper/blob/ff2e15eb4514d6728993442ab22bba2f6659a2f5/src/Helper.php#L69-L81
Edit
To get things working, we actually have to extend the last method, by filtering the login URL, WordPress places in the login form, since our logic would prevent a login otherwise.
/**
* When we are on the login page, we have to add the nonce, to the form[action*="wp-login.php"].
*/
if (Helper::isLoginPage()) {
add_filter(
hook_name: 'site_url',
callback: [$this, 'filterLoginUrl'],
);
}
And:
/**
* Filter login URL.
*/
public function filterLoginUrl(string $url): string
{
return add_query_arg(
'my_hard_login_nonce',
wp_create_nonce('my_hard_login_nonce'),
$url,
);
}