Summary
recordFailedLogin writes back the entire user row loaded at the start of login, and UpdateUser sets all 18 columns with no version or conditional predicate. Anyone who knows an email address can trigger it. Interleaved with a legitimate write, the stale snapshot reverts a just-changed password hash, MFA state, group membership or deactivation. The same race loses lockout increments, so the 5-attempt lockout is soft under parallel guessing.
Location
internal/auth/service_user.go:767 at 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd
Failure scenario
recordFailedLogin writes the entire user row it loaded at the start of Login (UpdateUser sets all 18 columns, store_postgres.go:193-240). Anyone who knows the email can trigger it at will. Interleave it with a legitimate write: the victim changes password (ChangePassword writes the new hash), an in-flight failed login for the same email then writes its stale snapshot with the old hash, old MFA state or old group_ids, silently reverting the change; the same race lets an admin's deactivation or group removal be undone by the stale write. It also loses lockout increments (two concurrent failures both write n+1), so the 5-attempt lockout is soft under parallel guessing.
Evidence
func (s *Service) recordFailedLogin(ctx context.Context, user *User) {
user.FailedLoginAttempts++
now := time.Now()
user.UpdatedAt = now
...
if err := s.store.UpdateUser(ctx, user); err != nil {
Suggested fix
Give the store a targeted atomic statement for this path (UPDATE users SET failed_login_attempts = failed_login_attempts + 1, locked_until = CASE ... WHERE id = $1) and use it from recordFailedLogin and completeSuccessfulLogin instead of the full-row write.
Found by the 2026-09-02 codebase audit, finding A03-010, reported by one reviewer and independently confirmed by a second. Full report: docs/audits/codebase-audit-2026-09-02.md.
Summary
recordFailedLoginwrites back the entire user row loaded at the start of login, andUpdateUsersets all 18 columns with no version or conditional predicate. Anyone who knows an email address can trigger it. Interleaved with a legitimate write, the stale snapshot reverts a just-changed password hash, MFA state, group membership or deactivation. The same race loses lockout increments, so the 5-attempt lockout is soft under parallel guessing.Location
internal/auth/service_user.go:767at 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cdFailure scenario
recordFailedLoginwrites the entire user row it loaded at the start ofLogin(UpdateUsersets all 18 columns, store_postgres.go:193-240). Anyone who knows the email can trigger it at will. Interleave it with a legitimate write: the victim changes password (ChangePasswordwrites the new hash), an in-flight failed login for the same email then writes its stale snapshot with the old hash, old MFA state or oldgroup_ids, silently reverting the change; the same race lets an admin's deactivation or group removal be undone by the stale write. It also loses lockout increments (two concurrent failures both writen+1), so the 5-attempt lockout is soft under parallel guessing.Evidence
Suggested fix
Give the store a targeted atomic statement for this path (
UPDATE users SET failed_login_attempts = failed_login_attempts + 1, locked_until = CASE ... WHERE id = $1) and use it fromrecordFailedLoginandcompleteSuccessfulLogininstead of the full-row write.Found by the 2026-09-02 codebase audit, finding
A03-010, reported by one reviewer and independently confirmed by a second. Full report:docs/audits/codebase-audit-2026-09-02.md.