Found while reviewing the #173 fix. #173 removed the hashing asymmetry on login; this is the residual leak one layer up, in AuthService.LoginAsync.
The leak
After #173, verifying a non-existent account costs the same Argon2 pass as verifying a real one, so the hasher itself reveals nothing. But the code path afterwards is not symmetric:
var passwordValid = passwordHasher.Verify(password, user?.PasswordHash); // now equal cost
var now = clock.GetCurrentInstant();
if (user is not null && !passwordValid && !lockout.IsLockedOut(user, now))
await lockout.RecordFailedAttemptAsync(user, now, ct); // KNOWN user only
A known username with a wrong password performs a database write. An unknown username skips it entirely.
Why it matters more than it looks
The delta is a Postgres round trip — milliseconds. That is orders of magnitude larger than the asymmetries considered negligible inside the hasher (a Base64 parse, a fixed-time compare: microseconds). So this is now the dominant timing signal on the login path, not a rounding error.
Scenario. An attacker posts /api/v1/auth/login with a deliberately wrong password against a list of candidate usernames and times the responses. Existing accounts come back consistently slower by one write. Username enumeration works, which is precisely what the dummy-hash defence was introduced to prevent.
⚠️ Pre-existing, not introduced by #173 — the old precomputed-dummy-hash version had the identical asymmetry. What changed is that it is now the only remaining one, so it is worth fixing rather than lost in noise.
Options, none free
- Write unconditionally. Record a failed attempt for a sentinel/non-existent user too, so both branches pay a write. Symmetric, but it means writing rows for usernames that do not exist — a storage and lock-contention channel an attacker controls.
- Move the write off the response path. Queue the failed-attempt record and respond without awaiting it. Removes the delta, but loses the ordering guarantee lockout currently relies on and introduces a durability gap.
- Pad the response to a fixed floor. Delay every login response to a constant target. Hides the write and any future asymmetry, at the cost of making every login as slow as the slowest branch.
- Accept it, and say so. Rate limiting is already configured; whether that reduces enumeration to an acceptable risk is a judgement call rather than something to discover later. If this is the answer, it belongs in an ADR, not an unstated assumption.
📌 Option 4 is a legitimate outcome. The point of this issue is that the choice gets made explicitly — the code currently reads as though the defence is complete.
Done when
Context: #173, and the comments added alongside it.
Found while reviewing the #173 fix. #173 removed the hashing asymmetry on login; this is the residual leak one layer up, in
AuthService.LoginAsync.The leak
After #173, verifying a non-existent account costs the same Argon2 pass as verifying a real one, so the hasher itself reveals nothing. But the code path afterwards is not symmetric:
A known username with a wrong password performs a database write. An unknown username skips it entirely.
Why it matters more than it looks
The delta is a Postgres round trip — milliseconds. That is orders of magnitude larger than the asymmetries considered negligible inside the hasher (a Base64 parse, a fixed-time compare: microseconds). So this is now the dominant timing signal on the login path, not a rounding error.
Scenario. An attacker posts
/api/v1/auth/loginwith a deliberately wrong password against a list of candidate usernames and times the responses. Existing accounts come back consistently slower by one write. Username enumeration works, which is precisely what the dummy-hash defence was introduced to prevent.Options, none free
📌 Option 4 is a legitimate outcome. The point of this issue is that the choice gets made explicitly — the code currently reads as though the defence is complete.
Done when
AuthService.LoginAsyncupdated; it currently names this leak and points hereContext: #173, and the comments added alongside it.