A sign-in form should not tell a stranger which email addresses have accounts here. So the refusal is deliberately vague: an unknown address and a wrong password produce exactly the same 401 and exactly the same sentence.
I was pleased with that until I looked at what the code actually did:
if not user or not verify_password(password, user.password_hash):
raise Unauthorized("Incorrect password or email")
Read the or. If there is no user, Python never evaluates the second half —
verify_password is not called at all.
Why that matters
Argon2 is slow on purpose. Hashing a password takes tens of milliseconds, and that is the point: it makes guessing expensive.
So the two refusals took very different amounts of time. A wrong password against a real account spent those tens of milliseconds. An unknown address came back almost instantly, because nothing was hashed.
The message was identical. The response time was not. And a response time is enough to sort a list of addresses into "registered here" and "not" — which is precisely the information the identical message was written to withhold.
The fix
Always spend the time, even when there is nothing real to check against:
DUMMY_HASH = hash_password("this hash exists only to spend the same time an account lookup would")
if user is None:
verify_password(password, DUMMY_HASH)
raise Unauthorized("Incorrect password or email")
Then I found the second one. A locked-out account was also refused early, before any hashing — so lockout was distinguishable from a wrong password by timing too. Same treatment: verify first, refuse after.
Three branches now leave by the same door, at the same speed.
Testing something you cannot measure
The obvious test is to time both requests and compare. It is also a bad test: timings in CI are noisy, and a test that fails on a busy runner gets muted within a week.
So I tested the cause instead of the symptom:
with patch("blog.services.auth.verify_password", wraps=real_verify_password) as mock_verify:
response = await client.post("/api/users/token", data={...})
assert response.status_code == 401
mock_verify.assert_called_once()
If a short-circuit ever comes back, the call count drops to zero and the test fails immediately, on any machine, at any load.
What stayed with me is how ordinary the bug looked. if not user or not
verify_password(...) is idiomatic Python — short-circuiting is a feature, and
in almost every other context skipping unnecessary work is correct.
Here the unnecessary work was the feature.