I Locked Myself Out Testing My Own Brute-Force Protection

Short answer

I tightened my login error messages so they would stop confirming which usernames
exist. The filter I wrote was too broad, and it also silenced my login limiter’s
warnings — so a plugin that was working perfectly started looking dead. The only
way to find out which it was: fail three logins on purpose and see whether I got
locked out. I did — a twenty-minute lockout, and the most reassuring error message
of the week.

The change that made a working plugin look broken

I had just finished stopping the site from
announcing its admin username in five different places.
One of those places is the login form, which by default tells you when a username
does not exist — a different message from the one you get for a real username
with a wrong password. That difference is a free username oracle.

So I normalised it. Every failed login, one message.

That is where I went wrong. Not in the goal — in the blast radius.

WordPress runs failure text through the login_errors filter, and I had hooked it
with something that returned my fixed string no matter what came in. That does
stop the oracle. It also stops every other message that arrives by the same
route
, and one of those routes belonged to Limit Login Attempts Reloaded, which
I had installed earlier the same day.

Its attempts-remaining warning: gone. Its lockout notice: gone. From where I was
sitting, the login screen had become a wall that said exactly one thing, forever,
in every circumstance.

Was it working? There was no way to look

Here is the uncomfortable part. I could not tell whether the plugin was doing its
job, and neither could you from the same position.

Think about what a login limiter looks like from the outside before you cross its
threshold. You type a wrong password, you get a failure. You type another wrong
password, you get a failure. A limiter that is counting and a limiter that is
disabled produce byte-identical output until the moment one of them stops you.
The only difference between working and broken lives on the other side of a line
you have to actually cross.

I could open the settings screen, of course. It said the plugin was active and
configured. But a settings screen tells you what software intends to do, not what
it does — and I had just spent an evening on a
failure that reported success
on this same site, so I was in no mood to trust an intention.

There was exactly one way to find out.

Attacking myself

Three deliberately wrong passwords.

The fourth attempt did not get a password prompt. It got this:

Too many failed login attempts.
Please try again in 20 minutes.

The plugin was fine. It had been fine the whole time. It simply had no way to say
so, because I had taped over its mouth on the way past.

I want to record the emotional shape of this accurately, because it is funny and
also the point. I had just locked myself out of my own site, on purpose, by
successfully attacking it, and my honest reaction was relief. The lockout was not
the failure. The lockout was the test result. It was the first hard evidence
all day that any of this hardening did anything at all.

The fix: normalise three error codes, not all of them

The correct move is narrower than the one I made. Rather than rewriting the final
message for every failure, intercept authentication and rewrite only the specific
error codes that leak whether an account exists:

add_filter('authenticate', function ($user) {
    if (is_wp_error($user)) {
        $leaky = ['invalid_username', 'invalid_email', 'incorrect_password'];
        if (in_array($user->get_error_code(), $leaky, true)) {
            return new WP_Error('auth_failed', 'Invalid credentials.');
        }
    }
    return $user;
}, 30);

Three codes go through the shredder. Everything else — your limiter’s countdown,
its lockout notice, whatever else your stack needs to tell you — passes through
untouched.

The general shape of the mistake is worth naming, because it is easy to repeat:
I filtered by channel when I should have filtered by content. login_errors
is a channel. Lots of unrelated things use it. What I actually wanted was to
suppress three specific messages, and I should have selected on those rather than
on the pipe they happened to travel through.

How to test a lockout without stranding yourself

Do this deliberately, not accidentally. A short checklist, learned the direct way:

  1. Know your way back in before you start. A second administrator account is
    the clean answer. Otherwise, accept up front that you are going to sit out the
    window.
  2. Know the window. Twenty minutes is the default here. It clears itself; you
    do not need to do anything. But twenty minutes is a long time if you started
    this five minutes before you needed to publish something.
  3. Do not test on the way to somewhere else. Test when being locked out costs
    you nothing.
  4. Watch for what the lockout does not tell you. If you cross the threshold
    and get the same generic failure as before, with no lockout notice, you have my
    bug — the plugin may be working while something upstream eats its output.
  5. Check by IP, not by account. This class of plugin blocks the address, not
    the user. That is why an attacker cannot use the threshold to lock you out of
    your own account on purpose — and also why you should re-check the settings if
    you ever put a CDN in front of the site, since every visitor can start looking
    like one address.

The pattern underneath all of this

This is the third time in a week I have written a version of the same sentence,
so it has earned being stated plainly.

A missing error is not evidence. My site had a fatal error I could not see. It had
a broken image pipeline that never errored at all. And now it had a security
control that was working perfectly and looked exactly like one that was not.

Absence of a signal is compatible with everything: with success, with failure,
with a component that was never installed. The only way to convert it into
information is to make the system produce an outcome — trigger the fatal, read the
generated sizes back, cross the threshold on purpose.

For security controls this stops being a nice principle and becomes the whole
game. A defence you have never seen fire is a defence you are assuming. The day
you find out whether the assumption held should not be the day someone else is
choosing.

It is the same rule I lean on hardest when an agent is making the changes rather
than my own hands — the one habit I would keep if I could only keep one.

FAQ

How do I test whether my WordPress login limiter is working?

Trigger it deliberately. Enter a wrong password until you cross the threshold and
confirm you are actually blocked. Reading the settings screen tells you what the
plugin intends to do, not what it does. Make sure you have a way back in first, or
be willing to wait out the lockout window.

Why did my login limiter stop showing warnings?

Something is probably filtering login messages. The login_errors filter replaces
the text WordPress shows on a failed login, and a filter that returns one fixed
string for every case will also swallow your limiter’s attempts-remaining and
lockout notices. The plugin keeps working; it just goes silent.

How long does a Limit Login Attempts lockout last?

Twenty minutes by default, after which it clears itself. The plugin locks by IP
address rather than by account, so an attacker cannot use the threshold to lock a
specific user out on purpose.

Wrapping up

The plugin was never broken. My hardening was too wide, and the thing it silenced
happened to be the one component whose entire job is to speak up.

If you take one habit from this: after you install a security control, make it
fire once. Not on a staging copy, on the thing you are actually protecting, at a
moment when being locked out for twenty minutes is merely annoying. You will
either learn that it works, or you will learn something considerably more
important.

A working control that cannot tell you it is working is one of five failures on
this site that all looked like success. They are collected, with
the one question that catches them,
in their own post.

Primary sources:
login_errors
· authenticate
· Limit Login Attempts Reloaded
· OWASP: Credential stuffing