An editor on a client's membership site suddenly could not log in. The password was correct, and to rule it out completely we reset it anyway. Still rejected, with nothing but a generic message: invalid credentials, or too many attempts. No detail, no hint, just a wall. And here is the part that stung: I built that wall. The login throttle was my own code.
It was not even the first time that code bit its owner. Some time earlier, the same throttle had locked our own tester's IP and username for days, and the symptoms disguised themselves so convincingly as a session cookie bug that we spent the whole time chasing the wrong suspect.
The dead ends I tried first
First reflex: credentials. The password had just been reset, so that was out. Second reflex, thanks to the tester incident trauma: suspect session cookies again. Cleared cookies, switched browsers, tried incognito. Still blocked.
Only then did I open the throttle's own data. It keeps a counter per username, so I checked the editor's username bucket. Result: clean. Zero failed attempts, no lockout.
That was the most misleading moment of the whole story. If the username bucket is clean, where does "too many attempts" come from? For a few minutes I genuinely entertained the idea that the throttle was innocent and the problem lived in some other layer.
The real culprit: the office IP bucket, not the username bucket
I had designed the throttle with two keys: per-IP and per-username, each with exponential backoff. The editor's username bucket really was clean. What was locked was the IP bucket.
And here is the detail that explains everything: the editor logs in from an office. That office IP is shared, one public address behind NAT for everyone on it. When I dumped the contents of that IP bucket, it was not the editor's history at all. It was bots: 4337 attempts against the username admin and 386 against editor, a classic spray pattern.
Exponential backoff made it worse. Thousands of attempts meant the lock window on that IP kept stretching until it was effectively permanent. And every person logging in from behind that NAT, no matter how correct their credentials, was treated as coming from the same IP as the bots. This is exactly the pattern OWASP warns about with IP-based lockouts: behind NAT or a shared proxy, one bad actor is enough to turn everyone else into collateral.
The fix
Put out the fire first
Before any redesign, people had to be able to log in that same day. The throttle stores its state as transients prefixed fin_lf_, so the emergency flush was one PHP CLI command:
php -r 'require "wp-load.php"; global $wpdb; $k=$wpdb->get_col("SELECT REPLACE(option_name,\"_transient_\",\"\") FROM $wpdb->options WHERE option_name LIKE \"_transient_fin_lf_%\""); foreach($k as $t) delete_transient($t); echo count($k);'One command, every counter cleared, the editor was back in immediately.
The redesign: identity-based, not just IP-based
The new principle fits in one sentence: an IP block may only be triggered by usernames that do not resolve to a real account.
The logic goes like this. Enumeration bots guess usernames, and the vast majority of those guesses do not exist in the system. Real users, almost by definition, type a username that does. So the IP bucket now only counts and blocks attempts against unknown usernames. Attempts against accounts that actually exist never touch the IP block; those are governed solely by the per-user bucket with exponential backoff, so a brute force run against a single account is still contained.
$user = get_user_by( 'login', $username );
if ( ! $user ) {
// Username does not resolve to a real account: the IP bucket counts only this.
// The signature of an enumeration bot.
bump_ip_bucket( $ip );
return;
}
// Real accounts never hit the IP block.
// They are only subject to the per-user bucket with exponential backoff.
bump_user_bucket( $user->ID );With this design, the bot spray against admin and editor still piles up in its source IP bucket, but office staff logging in with genuine accounts sail straight through.
One bonus move with the most satisfying payoff: we renamed the admin username. The spray stopped, and the counter dropped to zero. The bots' favorite target simply left the board.
The takeaway
- A clean username bucket does not mean the user is not throttled. Check every key dimension: IP, username, and their combination.
- IP-based lockouts behind NAT or shared IPs punish innocent people. OWASP has warned about this pattern for a long time.
- A fairer design: IP blocks only for usernames that do not resolve to real accounts; existing users are contained by the per-user bucket alone.
- Keep an emergency CLI path to flush throttle state, because the first victim is usually your own user, or you.
- Renaming the
adminaccount is cheap, and in this case it demonstrably stopped the spray cold.
