Securing WordPress Login with PHP Rate Limiting
Securing a WordPress login against brute force with rate limiting in PHP means slowing repeated authentication failures before an attacker can test thousands of passwords. The familiar login form is only one target: XML-RPC, REST API routes and password-reset endpoints can also expose authentication workflows. A useful limiter combines address and account counters, temporary cooldowns and a generic rejection message.
Rate limiting belongs within a layered security approach. Strong passwords, multi-factor authentication, timely updates and server-level filtering still matter, but application-level throttling can reduce repeated password-hashing work and stop simple attacks before they succeed. PHP makes this practical on Australian shared hosting, where administrators may not have access to rewrite web-server configuration.
Limits must accommodate shared networks without helping attackers. Many offices, university facilities and mobile carriers in Sydney, Melbourne and Brisbane place several users behind one public IP address. Blocking that address after a handful of failures could prevent legitimate staff from signing in, so combining an IP limit with a separate account limit gives administrators more control.
The example below uses a small WordPress plugin and MySQL-backed counters. It records keyed hashes instead of raw addresses, performs atomic counter updates and cleans up expired rows. For multisite installations, the table should normally be created for each site that needs protection.
Understand the attack surface
WordPress login attacks range from simple password dictionaries to credential stuffing with credentials leaked from unrelated breaches. Attackers also search for valid usernames, try a small set of common passwords against many accounts, or repeatedly call XML-RPC methods. Blocking only wp-login.php therefore leaves useful paths open, while blocking every request containing the word “login” can break legitimate services.
Australian retailers running WooCommerce often operate customer accounts, staff areas and REST integrations through the same WordPress installation. Valid customer logins and checkout requests should never be treated as suspicious simply because they arrive frequently. The limiter needs to distinguish rejected authentication attempts from successful sessions and must not introduce long waits into the purchasing journey.
Build a database-backed PHP limiter
Install the following as wp-content/plugins/login-guard/login-guard.php, then create and activate the plugin from the WordPress Plugins screen. Activation creates a table with a unique bucket key, attempt count, window start and optional block expiry. Each key represents either an IP address or a username, and WordPress authentication salts are used to produce a keyed HMAC that does not expose the original value.
<?php
/**
* Plugin Name: Login Guard
* Description: Limits repeated WordPress login failures.
*/
if (! defined('ABSPATH')) {
exit;
}
final class Login_Guard {
const WINDOW = 15;
const IP_MAX = 10;
const USER_MAX = 5;
const IP_BLOCK = 15;
const USER_BLOCK = 30;
public static function activate() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table();
$sql = "CREATE TABLE {$table} (
bucket_key char(64) NOT NULL,
attempts bigint(20) unsigned NOT NULL DEFAULT 0,
window_started datetime NOT NULL,
blocked_until datetime DEFAULT NULL,
PRIMARY KEY (bucket_key),
KEY blocked_until (blocked_until)
) {$wpdb->get_charset_collate()};";
dbDelta($sql);
wp_clear_scheduled_hook('login_guard_cleanup');
wp_schedule_event(time() + 3600, 'daily', 'login_guard_cleanup');
}
public static function table() {
global $wpdb;
return $wpdb->prefix . 'login_guard_limits';
}
public static function key($scope, $value) {
return hash_hmac(
'sha256',
$scope . '|' . strtolower((string) $value),
wp_salt('auth')
);
}
public static function ip() {
$candidate = filter_var(
$_SERVER['REMOTE_ADDR'] ?? '',
FILTER_VALIDATE_IP
);
return $candidate ?: 'unknown';
}
public static function blocked(array $keys) {
global $wpdb;
$table = self::table();
$placeholders = implode(',', array_fill(0, count($keys), '%s'));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT bucket_key, blocked_until
FROM {$table}
WHERE bucket_key IN ({$placeholders})",
...$keys
));
foreach ($rows as $row) {
if (
! empty($row->blocked_until)
&& strtotime($row->blocked_until . ' UTC') > time()
) {
return true;
}
}
return false;
}
public static function record_failure($scope, $value, $maximum, $block) {
global $wpdb;
$table = self::table();
$key = self::key($scope, $value);
$cutoff = gmdate(
'Y-m-d H:i:s',
time() - (self::WINDOW * 60)
);
$sql = $wpdb->prepare(
"INSERT INTO {$table}
(bucket_key, attempts, window_started, blocked_until)
VALUES (%s, 1, UTC_TIMESTAMP(), NULL)
ON DUPLICATE KEY UPDATE
blocked_until = IF(
window_started < %s,
NULL,
IF(
attempts + 1 >= %d,
DATE_ADD(UTC_TIMESTAMP(), INTERVAL %d MINUTE),
blocked_until
)
),
attempts = IF(
window_started < %s,
1,
attempts + 1
),
window_started = IF(
window_started < %s,
UTC_TIMESTAMP(),
window_started
)",
$key,
$cutoff,
$maximum,
$block,
$cutoff,
$cutoff
);
$wpdb->query($sql);
}
public static function check($user, $username, $password) {
$keys = [self::key('ip', self::ip())];
if ((string) $username !== '') {
$keys[] = self::key('user', $username);
}
if (self::blocked($keys)) {
if (! headers_sent()) {
status_header(429);
}
return new WP_Error(
'login_rate_limited',
'Login temporarily unavailable. Please try again later.',
['status' => 429]
);
}
return $user;
}
public static function failure($user, $username, $password) {
if (
$user instanceof WP_Error
&& $user->get_error_code() === 'login_rate_limited'
) {
return $user;
}
if ($user instanceof WP_Error) {
self::record_failure(
'ip',
self::ip(),
self::IP_MAX,
self::IP_BLOCK
);
if ((string) $username !== '') {
self::record_failure(
'user',
$username,
self::USER_MAX,
self::USER_BLOCK
);
}
}
return $user;
}
}
register_activation_hook(__FILE__, ['Login_Guard', 'activate']);
add_filter('authenticate', ['Login_Guard', 'check'], 5, 3);
add_filter('authenticate', ['Login_Guard', 'failure'], 99, 3);
add_action('login_guard_cleanup', function () {
global $wpdb;
$table = Login_Guard::table();
$cutoff = gmdate(
'Y-m-d H:i:s',
time() - (2 * Login_Guard::WINDOW * 60)
);
$wpdb->query($wpdb->prepare(
"DELETE FROM {$table}
WHERE window_started < %s
AND (blocked_until IS NULL OR blocked_until < %s)",
$cutoff,
$cutoff
));
});
register_deactivation_hook(__FILE__, function () {
wp_clear_scheduled_hook('login_guard_cleanup');
});
The early authenticate filter checks both counters before WordPress processes the password. The later filter records only rejected attempts. Its MySQL upsert increments the counter in one operation, avoiding the lost updates that can occur when separate read and write requests arrive simultaneously. A rejected attempt counts against both the address and the supplied username, so password spraying across several addresses still encounters the account limit.
Test the plugin on a staging copy with a disposable account. The default settings allow ten IP failures and five account failures within fifteen minutes. The IP bucket receives a fifteen-minute cooldown, while the account bucket receives thirty minutes. Avoid repeatedly locking production administrators during testing, and review the database and error messages on both the normal login form and XML-RPC before enabling it for the whole site.
Tune thresholds without locking out customers
The IP limit should tolerate shared connections, while the account limit can be stricter because a username should normally belong to one person. Shared WordPress accounts are poor practice for Australian businesses because staff turnover, shared ownership and weak handover procedures create additional risks. If a business genuinely uses a shared login, increase the account threshold and rely on network access controls and multi-factor authentication.
A blocked response should not reveal whether an account exists. The example returns the same message for an IP block and an account block. WordPress’s normal invalid-credential messages can still disclose whether a username was recognised, so administrators who require uniform authentication errors should also review that wording. Rate limiting reduces repeated guessing; it does not remove every username-enumeration technique.
Retaining authentication-related identifiers also carries privacy considerations. An IP address can be personal information under the Australian Privacy Principles in the Privacy Act 1988 when it can reasonably be linked to an identifiable person. A keyed hash reduces exposure, but it is not an automatic exemption from the rules where the Act applies. Document what is retained, restrict access and delete expired rows promptly rather than treating hashing as complete compliance.
Cover proxies, APIs and XML-RPC
The example deliberately uses REMOTE_ADDR instead of trusting arbitrary X-Forwarded-For headers, which attackers can forge. Behind Cloudflare, a reverse proxy or a load balancer, the server may receive only the proxy’s address, causing every visitor to share one bucket. Configure the web server or trusted proxy to restore the verified client address, then allow the plugin to obtain that address from the server’s remote-address configuration.
XML-RPC deserves separate attention because its multicall method can multiply authentication attempts. Restrict it with a firewall rule, disable unnecessary pingbacks and XML-RPC methods through vetted security controls, or block access entirely when no compatible application needs it. REST API traffic should follow a different path: valid WooCommerce application passwords and OAuth tokens must remain usable, while repeated rejected credentials need appropriate limits.
Infrastructure planning matters for larger deployments, but it cannot replace application controls. Background such as a two-node vSAN guide can support resilience and capacity decisions, while WordPress still needs its own authentication protections. Check these layers independently, because a perfectly configured database-backed limiter can still be undermined by an upstream proxy that supplies the wrong client address.
Monitor, maintain and verify the controls
Keep a record of blocked-login volume, successful logins from unusual locations and administrative password changes. The Australian Signals Directorate’s Australian Cyber Security Centre recommends multi-factor authentication, reliable logging and timely patching as core security practices, all of which complement rate limiting. A sudden rise in blocked requests can indicate credential stuffing, while a cluster of legitimate staff being denied usually points to shared IP handling or thresholds that are too strict.
Store the plugin under version control, document its thresholds and test the response after WordPress, PHP or proxy changes. Rotating WordPress authentication salts changes the generated bucket keys and effectively abandons existing counters, so treat that rotation as part of the security runbook. Database backups and cleanup scheduling should also be checked, particularly on sites where login activity forms part of an incident investigation.
Operational recommendations for WordPress administrators:
- Require multi-factor authentication for administrators and editors, including Australian sites that administer WooCommerce stores remotely.
- Keep separate address and account limits, then raise the address threshold for confirmed shared networks.
- Restrict XML-RPC and unused API methods instead of relying on login throttling alone.
- Review counters, cleanup jobs and rejected-login logs after plugin updates, proxy changes or unusual traffic spikes.
- Test from a clean browser session, a valid account and a disposable account without repeatedly locking real administrators.