What happened
I mistyped my password. My password manager had a stale entry and I did not notice, so I tried again, and again. On the fifth failure the auth service banned my IP address — permanently, with no expiry — and started refusing the correct password too.
The only way out was to SSH into the box as root and delete a row from SQLite. I did that. Then, ten minutes later, I did it again.
This is a brute-force protection working exactly as designed, against the one user it should never stop.
Why it was impossible to diagnose
Here is the part I find more interesting than the ban itself. The login form returns a deliberately generic error — credentials or 2FA incorrect — and it should. If a failed login tells you no such user, you have handed an attacker a way to enumerate accounts.
But nothing was written to the server log either. So the real reason existed nowhere. Not in the browser, by design, and not on the machine, by omission. Five failures produced a permanent ban and no way to find out what had actually gone wrong.
That gap mattered more than it sounds, because two very different failures look identical from the browser: wrong password, and password correct but the 2FA code was missing. With a password manager involved, those have completely different causes and completely different fixes. I could not tell them apart, so I kept trying the thing that was already right.
The compounding part
No single decision here was wrong. Generic errors prevent enumeration. Rate-limiting stops credential stuffing. Not logging secrets is basic hygiene. Each one is what you would write on a whiteboard.
Stacked, they produced a system where the failure mode is total, permanent, and silent — and the recovery path assumed I still had root SSH, which is exactly the assumption you cannot make about the general case. There is no version of "have you tried SSHing into the server" that works for a normal user.
I had also, without ever deciding to, made the mail server a dependency of account recovery. There was no email reset because the mail service was down — and had been for a while, harmlessly, right up until the moment it was the only way back into everything.
What I changed
Bans expire. Fifteen minutes by default. Brute-force protection does not need to be permanent to work: five tries and a cooling-off period costs an attacker everything and costs the owner fifteen minutes. The message now says how long is left, instead of sounding final.
The reason is logged server-side. No such user, wrong password, or password correct with the 2FA code missing or wrong. The client still learns nothing — enumeration resistance is intact — but the answer now exists somewhere a person can read it.
Trusted addresses are never banned. Loopback, RFC1918, and the Tailscale CGNAT range. Traffic from there is already inside the perimeter, and banning it locks the operator out of the machine they would use to lift the ban.
Recovery that does not depend on email. Enrolling in two-factor now issues ten single-use recovery codes. One works anywhere an authenticator code is asked for, and one authorises a self-service password reset. Possession of the second factor stands in for "you control the registered address", because there is no registered address to control.
That last one has a deliberate limit: an account with 2FA disabled gets no self-service reset at all. There would be nothing to check, and a username alone is not a credential.
What I took from it
A security control needs an escape hatch that does not require the thing it is protecting. "Fail closed" is correct, but a system that fails closed with no way back has not failed safely — it has just failed.
Refusing to tell the user is not a reason to refuse to tell yourself. Enumeration resistance is about what crosses the network boundary. I had conflated "the client must not learn this" with "this must not be recorded", and they are not the same statement.
Watch what quietly becomes load-bearing. The mail server being down was a known, tolerated, low-priority problem. It stayed that way right up until it was the only path to account recovery, at which point it had been critical for weeks without anyone deciding it was.
The lockout cost me an evening. The interesting part is that I had written every one of those controls on purpose, and still built a door that only locks from the inside.
