Mail 2 — Backend response

Confirmed response shapes and behavior for Module 1 edge cases. Summary of what is implemented and what was not implemented for security reasons.

Implementation status

Everything Hari requested has been implemented in the backend unless noted otherwise in the “Not implemented (security)” section below.

Already implemented

# Requested behavior Status
1 Social vs email collision: email/password on social-only → error; social on existing email → auto-link Implemented
2 Forgot password for social-only: always 200, no email/token sent, same message Implemented
3 Apple relay email stored as primary; two accounts if same person uses Google with real email; missing name accepted Implemented
4 Login error codes: WRONG_PASSWORD, ACCOUNT_NOT_FOUND, ACCOUNT_SOCIAL_ONLY, ACCOUNT_LOCKED, ACCOUNT_NOT_VERIFIED, TOO_MANY_ATTEMPTS; shape { error: { code, message, retry_after? } } Implemented
5 Rate limiting: 5 failed logins → account locked 15 min (ACCOUNT_LOCKED / TOO_MANY_ATTEMPTS + retry_after); forgot-password 3/hour per email (TOO_MANY_REQUESTS + retry_after) Implemented
6 Email case-insensitive; backend normalizes to lowercase Implemented
7 Access 15 min, refresh 7 days; multi-session; REFRESH_TOKEN_EXPIRED vs REFRESH_TOKEN_REVOKED Implemented
8 Register returns tokens (auto-login); verification_pending documented for when verification is enabled Implemented
9 Forgot-password always 200; reset token 1h, single use; reset does not return tokens Implemented
10 SOCIAL_TOKEN_INVALID for invalid/expired idToken; PROVIDER_UNAVAILABLE (503) for provider down / testing with provider_unavailable Implemented

Not implemented for security reasons

Item Why not implemented
Revealing whether an email exists on login We keep the same user-facing message for ACCOUNT_NOT_FOUND and WRONG_PASSWORD (“Invalid email or password”) so an attacker cannot enumerate accounts by distinguishing “account not found” from “wrong password”. Implementing different messages would weaken security.
Different success message for forgot-password when account is social-only Returning a message like “You signed up with Google” would reveal account type. We always return the same generic message so we do not leak whether the email exists or how the account was created.

1. Social vs email account collision

2. Forgot password for social-only accounts

Always 200 with { "message": "If the email exists, a reset link has been sent." }. If the account is social-only we do not send email or create a token. Frontend shows the same generic message.

3. Apple Sign-In — hidden email & name

4. Login error differentiation

WRONG_PASSWORD — 401, message “Invalid email or password.”
ACCOUNT_NOT_FOUND — 401, same message (security).
ACCOUNT_SOCIAL_ONLY — 401, “Account was created with a social provider. Please sign in with Google or Apple.”
ACCOUNT_LOCKED — 429, “This account is temporarily locked. Try again later.” + retry_after (seconds).
ACCOUNT_NOT_VERIFIED — 401, “Please verify your email before signing in.” (when REQUIRE_EMAIL_VERIFICATION=true).
TOO_MANY_ATTEMPTS — 429 after 5 failed logins, message + retry_after (15 min in seconds).

5. Rate limiting & brute force

6. Email case sensitivity & format

Email is case-insensitive; backend normalizes to lowercase.

7. Token & session behavior

8. Register → login handoff

Register returns 201 with tokens (auto-login). When email verification is required, set REQUIRE_EMAIL_VERIFICATION=true; register can later return verification_pending and no tokens.

9. Forgot password flow details

Always 200, same message. Reset token 1 hour, single use. After reset, only message (no tokens); user must log in.

10. Social login — token validation failures

Invalid/expired idToken: 400, SOCIAL_TOKEN_INVALID.
Provider unavailable: 503, PROVIDER_UNAVAILABLE. For testing, send idToken: "provider_unavailable" or "provider_error".

ACCOUNT_NOT_VERIFIED is active only when REQUIRE_EMAIL_VERIFICATION=true. Run migration 20260311120000_auth_lock_and_forgot_rate_limit for lock and forgot-password rate limit tables.