The admin of our portals is reachable from the internet because the editors do not work from a single office. That means the login form is seen by every bot trying leaked passwords or looking for a way to overload the server. The design described here was put together in autumn 2026 after a security audit and several rounds of independent review. None of it is original, and all of it can be found in the OWASP guidance, NIST SP 800-63B-4 and the WebAuthn specification. The value lies in the order of the steps and in making sure the individual defences do not get in each other’s way. The article is for developers and architects of internal systems. We leave out concrete limits and configuration. The system has only run in this form since October 2026, so we write about the design and not about statistics.
What we defend against
The reviews showed that identity verification itself tends to be solved before everything that happens around it. The design therefore covers five situations:
- Replaying leaked e-mail and password pairs from other services (credential stuffing), often spread across many IP addresses.
- Availability attacks: deliberately slow password verification can be abused to overload the server.
- Locking out the owner: anyone who knows the e-mail address can trigger the account lockout with repeated wrong attempts.
- A stolen or abandoned session from which security settings could be changed.
- A forged request from another site (CSRF) that abuses a logged-in administrator.
Cheap checks before expensive ones
Bcrypt is slow on purpose, so that leaked password hashes are hard to crack. The same property makes it a target: every request that reaches it costs the server noticeable CPU time. The login therefore follows a fixed order in which each step is cheaper than the next. It starts with a check of the input’s shape, followed by a quota on requests per client address, which is deducted on every attempt before the server even looks at what the request wants. Next come a bot challenge and failure quotas per address and per account, and the password is checked last. For a non-existent account the server verifies the password against a dummy hash, so the response takes about as long as for a real one.
Only attempts that passed the bot challenge count towards the failure quotas. Otherwise a cheap bot could exhaust an account’s budget without ever trying a password. We do not overrate the challenge itself: OWASP describes it as an additional layer that makes attacks slower and costlier, not as a standalone control.
Atomic quotas, closed during an outage
A quota is a counter with a time window and a ceiling. Checking and deducting must happen in a single atomic database statement. If the application reads first and writes afterwards, ten concurrent requests see the same state and all of them get through. The account’s failure counter and the lock are updated just as atomically, so concurrent attempts cannot jump over the threshold and the lockout notification goes out exactly once.
The failure quota is deducted up front and refunded on a successful login. An attacker therefore cannot fire a hundred parallel attempts while none of them has finished yet. For IPv6 we count per block that typically belongs to one connection, not per individual address.
When the database does not respond, the security quotas refuse the login. An open door during an outage is not an acceptable state, and an outage is when someone is most likely to try it. The audit trail of attempts is kept separate from the quotas. It serves as evidence and not as a counter, so it may be capped during a bot flood without weakening the defence. OWASP’s credential stuffing guidance names the limit of the whole approach: per-IP limits should not be the primary defence, because an attack spread over thousands of addresses gets round them. For us they are only the first layer.
A lockout that does not lock out the owner
A classic lockout after a few failures has a weakness that OWASP also points out: anyone who knows the e-mail address can lock out anyone. NIST SP 800-63B-4 does require a cap on consecutive failures, but it also recommends techniques that reduce the chance of an attacker locking out the legitimate user.
We therefore distinguish between known and unknown browsers. After a successful login the browser receives a signed identifier bound to the current account version, which is checked against a server-side record every time a factor is verified. The record expires, the owner can see it in the profile and revoke it, and a password change invalidates all of them at once. A known browser is exempt from the account lock but has its own quota. Once that is used up, the browser is treated as unknown. The lock applies to unknown clients, so the owner can still get in during an attack on their e-mail address.
A known browser does not skip the password or the second factor, though. It only changes who the lock can hit. Every login from a new browser and every account lock sends the owner an e-mail. Notifications have their own limit so they cannot turn into a flood, and they never block the login, because an unavailable mail server must not stop the editors from working.
Second factor: WebAuthn with user verification
Outside local development the second factor is mandatory. The preferred second factor is a WebAuthn credential. For both registration and login we require user verification: the authenticator must verify the user locally, for example with a fingerprint or a PIN, rather than merely confirm a touch. This confirms local verification, not the person’s civil identity. If the signed response lacks the verification flag, the server rejects it, as the WebAuthn Level 3 specification (a W3C Recommendation since August 2026) describes for the “required” setting.
We compare the origin in the client’s signed data against an exact allow-list before the library processes it. Binding to the relying party and checking the origin make WebAuthn phishing-resistant, and NIST SP 800-63B-4 requires systems at AAL2 to offer at least one such option. We do not insist on a discoverable credential (resident key), because WebAuthn is the second step after e-mail and password, when the server already knows which account is involved. In FIDO terminology, passkeys are discoverable credentials. The system also accepts non-discoverable WebAuthn credentials, which are not passkeys in that strict sense.
The fallback factor is a one-time code sent by e-mail, with a short lifetime, a minimum interval between sends and quotas deducted before sending. NIST SP 800-63B-4 does not accept e-mail as an out-of-band authenticator. A mailbox is often protected by a password alone and the message can be intercepted or rerouted, so it does not prove possession of a specific device. The e-mail code is a compromise for the case of a lost device. The system can switch it off for accounts with passkeys, and we recommend doing so once the owner has registered more than one device.
Sessions and changes to security settings
Registering or deleting a passkey and changing the password all require the password to be entered again, bound to that specific operation, and this check also has its own quota ahead of the bcrypt computation. A stolen session is therefore not enough for an attacker to add their own passkey. This follows the OWASP advice to require current credentials before sensitive account details are changed.
The account carries a version number. A password or role change increments it, which invalidates the other running sessions and every known browser without having to find and delete individual sessions. After a password change only the session from which it was made stays valid. The role is read from the database on every request, not from the session, so revoked rights apply immediately. Locking the account does not end running sessions: the lock is there to stop guessing, not to throw out an owner who is already working.
Sessions have both an idle limit and an absolute lifetime. The cookie is Secure, HttpOnly and SameSite, with its path narrowed to the admin. Every POST request goes through a central check of the CSRF token and of the headers in which the browser reports where the request came from. According to OWASP, SameSite is only a defence-in-depth layer, not a replacement for the token. Access is denied by default: an anonymous visitor gets nothing but the login page, and the exceptions are listed in the code. Writes to live data and security events go to an audit log.
What we left out on purpose
- Artificial response delays (sleep). They hold a server process that cannot serve anyone else in the meantime, and they do not bother an attacker sending requests in parallel. A wait after a failure, which NIST also allows, should be server-side state (attempts are refused until a given time), not a blocked thread.
- Different messages for “no such account”, “wrong password” and “account locked”. One message and a comparable response time.
- Passwords in logs, error messages and HTML, including the moments when something breaks.
- Silent password truncation. Bcrypt processes at most 72 bytes and PHP silently discards the rest, so a longer new password is rejected rather than shortened.
- Mandatory character composition and routine periodic password changes. NIST SP 800-63B-4 prohibits these rules, but requires a change when there is evidence of compromise. Length alone is not enough: new passwords must also be checked against a blocklist of common and compromised values, and login attempts must be rate-limited.
- A honeypot as a security control. A hidden field for bots is at best a cheap noise filter. It is not part of the defence, and nothing in it depends on one.
Checklist and recommendation
When we assess the login of an internal system, our own or a client’s, we go through these questions:
- Is every login request counted before password verification starts?
- Are checking and deducting a quota a single atomic statement, and what happens when the database does not respond?
- Can a stranger lock out the administrator?
- Is a phishing-resistant factor available, and does it require user verification?
- Can a factor be added or the password changed from a stolen session?
- Does a password or role change end the account’s other sessions?
- Does the owner learn about a login from a new browser and about an account lock?
For a new system we recommend considering Argon2id. OWASP now recommends bcrypt only where neither Argon2id nor scrypt is available, and the 72-byte limit means that with accented characters (two bytes each in UTF-8) a password cannot even reach the 64 characters NIST recommends allowing as a minimum. We still use bcrypt, and a migration can happen gradually, at each user’s next login. Even so, the order of the steps makes a bigger difference than the algorithm or any single technique. Cheap checks come before expensive ones, the door stays closed during an outage, and the lockout is aimed at the attacker and not the owner.