Repository navigation
sec(auth): login endpoint leaks MFA enrollment status via distinct error messages #388
Description
Summary
The /api/auth/login handler passes err.Error() verbatim to the client (HTTP 401). The verifyPasswordAndMFA function returns semantically distinct error messages depending on MFA state, which allows an attacker to confirm both account existence and MFA enrollment:
| Condition | Error returned to client |
|---|---|
| Wrong email or password, user not found | invalid email or password |
| Correct email + password, MFA enabled, no code submitted | MFA code required |
| Correct email + password, MFA flag set but secret missing | MFA is enabled but not configured |
A network attacker who can attempt credentials can confirm whether an account exists and has MFA enabled by observing which 401 message is returned.
Location
internal/api/handler_auth.go:40—NewClientError(401, err.Error())internal/auth/service.go:182–191—verifyPasswordAndMFAdistinct MFA error strings
Reproduction
# Attempt login with correct credentials for a user with MFA
curl -X POST https://<lambda-url>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"known-user@example.com","password":"correctpassword"}'
# → {"error":"MFA code required"} ← confirms user exists and MFA is enrolledNote: live probing was not done for this finding due to the rate limiter. The finding is code-only.
Suggested fix
Map all authentication failures to a single generic message at the handler layer, regardless of internal cause. The MFA-specific messages are useful in the service layer for logging, but should not be forwarded to callers.
// handler_auth.go login()
response, err := h.auth.Login(ctx, loginReq)
if err != nil {
// Map MFA-specific messages to a generic 401 to prevent user enumeration.
// The internal error is already logged at the service layer.
return nil, NewClientError(401, "invalid email or password")
}The "MFA code required" flow needs a different UX signal — consider returning a structured body like {"error":"authentication_failed","mfa_required":true} only after the MFA-requiring path, or use a dedicated 2-step flow so MFA prompting doesn't confirm credentials.
Severity rationale
MEDIUM: Requires valid credentials to trigger the MFA-specific path (not a pure enumeration oracle — you need the password too), but it does confirm account existence + MFA enrollment state for credential-stuffing actors who managed to obtain matching passwords from a breach.
Activity
sec(auth): unify login error response for unknown vs wrong-password (c…
sec(auth): stop leaking MFA enrollment via login error responses (closes
sec(auth): unify login error response for unknown vs wrong-password (c…
sec(auth): stop leaking MFA enrollment via login error responses (closes
cristim commentedon Jun 3, 2026
The MFA-enrollment leak via login error responses is fixed by the merged PR #830 (closes #391), which collapses the missing-MFA-secret case into the generic wrong-credentials response so an attacker cannot distinguish enrollment state. PR #886 (which targeted this issue with a typed mfa_required response) was closed as superseded - #830's generic collapse is the stronger fix. Recommend closing this issue as resolved by #391/#830.
cristim commentedon Jul 27, 2026
Verification sweep against
main(101f099fb) on 2026-07-27 finds this already resolved.Login now collapses every non-sentinel failure into one generic 401.
Evidence: commit e31ed68 (#1107). Same root fix as #939.
Recommending close.
cristim commentedon Sep 2, 2026
Verified resolved at 3c0f8ac: the login handler no longer forwards err.Error(); wrong password, unknown user, missing password hash and MFA-enabled-but-secret-missing all collapse to a single 401 'invalid credentials', and the service layer returns one generic message for those paths. Only the machine-readable mfa_required / invalid_mfa_code sentinels remain distinct, which is the structured MFA signal this issue's suggested fix explicitly allows. Evidence: internal/api/handler_auth.go:47-58 and internal/auth/service.go:229-247 (#830, #1107). Residual axes checked: missing-password-hash path, MFA-secret-missing path, handler-layer message forwarding; none found. Closing as completed; reopen if the behaviour recurs.