Skip to content

Login Limiter

instance-login-limiter counts failed logins per address and locks out the ones that keep guessing. There is no admin screen: a lockout is released over WP-CLI, so an attacker who reaches an account cannot clear their own.

SettingDefaultMeaning
INSTANCE_LOGIN_LIMITER_MAX_ATTEMPTS3Failures against one username from one address before that pair is locked
INSTANCE_LOGIN_LIMITER_LOCKOUT30 minutesHow long a lockout lasts
INSTANCE_LOGIN_LIMITER_WINDOW1 hourHow far back failures are counted
INSTANCE_LOGIN_LIMITER_IP_THRESHOLD20Failures from one address across all usernames before the address itself is locked
INSTANCE_LOGIN_LIMITER_ALLOWLISTemptyAddresses that are never locked out, comma separated

Override any of them in config/application.php:

config/application.php
Config::define('INSTANCE_LOGIN_LIMITER_MAX_ATTEMPTS', 5);
Config::define('INSTANCE_LOGIN_LIMITER_LOCKOUT', 15 * MINUTE_IN_SECONDS);

The allowlist is already wired to .env, as an escape hatch for a fixed office or VPN address:

.env
INSTANCE_LOGIN_LIMITER_ALLOWLIST='203.0.113.10,203.0.113.11'

Failures are counted per pair of address and attempted username, not per address alone. Locking a whole address on three failures would take an office behind one connection offline because one person mistyped their password.

That leaves a gap: someone can try three passwords against admin, move on to editor, and keep going forever. The address wide threshold closes it — once an address racks up 20 failures across all usernames inside the window, the address itself is locked.

A successful login clears every counter for that address: the person sitting at it has proved they are not the one guessing.

Failures older than the window no longer count, so a typo last week does not stack with one today.

A locked out visitor gets one message with the minutes remaining, and it never differs between a real and an unknown username:

Error: Too many failed login attempts from this address. Try again in 30 minutes.

Reloading the login screen while locked out shows the same sentence without the error styling, so the visitor is not left staring at a form that cannot work.

Hardening replaces every other login error with one generic line, so the form cannot be used to find out which accounts exist. The lockout message is the one exception — it names no account, and without it someone who is locked out keeps retrying a password that was never the problem.

The limiter does nothing when WP_ENV is development. Change that list per project:

add_filter('instance_login_limiter_exempt_environments', function (): array {
return ['development', 'local'];
});
Terminal window
# What is currently locked
ddev wp instance lockout list
# Include addresses being counted but not yet locked
ddev wp instance lockout list --all
# Everything recorded against one address
ddev wp instance lockout status 203.0.113.10
# Release an address, or one attempted username on it
ddev wp instance lockout release 203.0.113.10
ddev wp instance lockout release 203.0.113.10 --login=editor
# Release everything
ddev wp instance lockout release --all
# Drop expired lockouts and stale counters by hand
ddev wp instance lockout prune

On a server, drop the ddev prefix.

Lockouts live in the {prefix}instance_login_lockouts table, created on first load and pruned daily by WP-Cron. A row with an empty username is the address wide lockout; every other row is one address and one attempted username.

Transients were the obvious alternative and do not work here: with a persistent object cache they never reach the database, so list would have nothing to read.

FilterUse
instance_login_limiter_enabledTurn the limiter off entirely
instance_login_limiter_exempt_environmentsEnvironments where nothing is locked out
instance_login_limiter_allowlistAddresses that are never locked out

Failed second factors. WP 2FA rejects a wrong one-time code through its own hooks, which this plugin does not listen to, so password guessing is rate limited and code guessing is not.