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 — verifyPasswordAndMFA distinct 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 enrolled
Note: 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.
Summary
The
/api/auth/loginhandler passeserr.Error()verbatim to the client (HTTP 401). TheverifyPasswordAndMFAfunction returns semantically distinct error messages depending on MFA state, which allows an attacker to confirm both account existence and MFA enrollment:invalid email or passwordMFA code requiredMFA is enabled but not configuredA 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 stringsReproduction
Note: 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.
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.