Password authentication
Implement login with generic errors
Compare password hashes safely, avoid user enumeration, rotate session state, and preserve consistent observable behavior.
Login does three things. It receives an identifier and a secret, finds the stored verifier, and creates a brand new session only after the comparison succeeds. Everything below is about not leaking information along the way.
One error for everything
The public contract is deliberately boring:
{ "error": "Invalid email or password" }
Unknown email, wrong password, disabled account: same status code, same body, same redirect. If “wrong password” and “no such user” look different, you’ve built an account enumeration oracle. An attacker feeds it a list of emails and learns which ones have accounts.
Internal logs can record a safe reason code, like unknown_account. They must never record the password or the session token.
Verify unknown accounts too
Timing leaks the same information. If an unknown email returns in 2 ms and a wrong password takes 150 ms of Argon2id, the attacker measures the difference.
So when the account doesn’t exist, load a dummy verifier, a fixed hash kept for this purpose, and run the library’s verify() against it anyway. Both paths now cost about the same. Always use the library’s comparison: it’s constant-time and reads the parameters from the encoded hash for you.
The order of the checks
Handle a login request in this order:
- Validate input shape and length.
- Apply cheap request-size, network, and global abuse limits.
- Load the account, or the dummy verifier.
- Run the password library’s comparison.
- Apply account-specific policy without changing the public outcome.
- Create a new session on success.
Steps 1 and 2 come first because hashing is expensive on purpose. A flood of requests should be rejected before it reaches step 4. Account-specific throttling sits at step 5, after the uniform real-or-dummy verification, so the response can’t reveal whether the account exists.
Always issue a fresh session
If the browser had an anonymous session cookie before login, don’t upgrade that identifier to an authenticated one. Create a new session, send a new cookie, invalidate the old value. This is session fixation protection: an attacker who planted or learned the pre-login identifier inherits nothing when the victim logs in.
Outages are not bad passwords
If the database or the hashing service is down, the answer is not “invalid credentials”. Return a generic temporary failure, 503 with a retry hint, and alert internally. Otherwise an outage looks like thousands of failed logins.
Try this on the Books API login endpoint: write tests for success, unknown email, wrong password, disabled account, storage failure, and a request that arrives with a pre-login session cookie. Assert on the full response body for every failure. Only success may create a session row, and its identifier must differ from any cookie the client sent.
Lesson completed