Skip to main content
Version: Latest
UPDATED 2026-09-04 Forgot-password will never tell you whether an email is registered — this is deliberate. An unknown email still reaches the OTP screen and fails there with the same generic error a wrong code gives. Don't build a "no account found with this email" message: the accounts it would reveal belong to children.
## What's new <!-- Newest first, plain language, max 5 entries. Every session that changes this guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. --> - **2026-09-01** — Forgot-password will never tell you whether an email is registered — this is deliberate. An unknown email still reaches the OTP screen and fails there with the same generic error a wrong code gives. Don't build a "no account found with this email" message: the accounts it would reveal belong to children. ---

Mobile Verification & Password Reset Guide

OTP-backed flows for signup verification (onboarding) and password reset. Both share the same /api/verification machinery — only the wrapping flow and the endpoints around it differ.

Companion to mobile-auth-integration.md and mobile-onboarding-guide.md.

What's new

  • 2026-09-01 — Forgot-password will never tell you whether an email is registered — this is deliberate. An unknown email still reaches the OTP screen and fails there with the same generic error a wrong code gives. Don't build a "no account found with this email" message: the accounts it would reveal belong to children.

Shared Headers

X-Client-Type: mobile
Content-Type: application/json

All endpoints below are unauthenticated (no Bearer token) — the caller has no account yet (signup) or is locked out (reset).


1. Onboarding Verification

Status: Email verification is OPTIONAL. Wireframe shows a popup OTP step between the "enter email" screen and the "set password" screen, but the server does NOT gate /complete on it. Mobile can:

  • Run the OTP popup for UX (proves the user owns the inbox), then continue the flow without sending code to /api/onboarding/session.
  • Skip the popup entirely. /api/onboarding/session accepts email + password alone; /complete succeeds.

Either path produces a working account. emailVerifiedAt will be null when the OTP is not threaded through to the session — that's expected today.

Each session uses exactly one of email or phoneNumber (XOR enforced server-side). Phone-channel onboarding is not yet live (no SMS adapter).

Step 1 — Request a code (popup screen)

POST /api/verification/send
{
"channel": "email",
"identifier": "user@example.com",
"purpose": "email_verification"
}

Response 200:

{ "success": true, "data": { "expiresIn": 300 } }

Code lifetime: 5 minutes. Max 5 sends per identifier per 15 min.

Step 2 — Confirm the code (popup verify button)

POST /api/verification/confirm
{
"identifier": "user@example.com",
"code": "123456",
"purpose": "email_verification"
}

Response 200:

{ "success": true }

The OTP is consumed (deleted server-side). On success, server also:

  • Stamps User.emailVerifiedAt = now() if a user with that email exists.
  • Stamps OnboardingSession.emailVerifiedAt = now() if an ACTIVE session with that email exists.
  • Writes a short-lived verified-contact:email:<email> flag in the key-value store (10-min TTL).

The flag bridges the popup-before-session case (Step 3 has not run yet). POST /api/onboarding/session consumes the flag on create and stamps the new session row. Result: emailVerifiedAt lands in the DB regardless of which order the mobile screens fire.

Step 3 — Start onboarding session

POST /api/onboarding/session
{
"email": "user@example.com",
"password": "SecurePass1"
}

code may be included if Step 2 was skipped — the server verifies inline and stamps emailVerifiedAt. If Step 2 already ran, omit code: the server picks up the verified-contact flag automatically.

If neither Step 2 ran nor code is supplied, emailVerifiedAt stays null on the session. /complete still succeeds (verification is advisory today).

Response 201:

{
"success": true,
"data": {
"onboardingToken": "<jwt>",
"session": { "id": "...", "expiresAt": "..." }
}
}

Store onboardingToken in SecureStore. Use it as Authorization: Bearer <token> for all subsequent /api/onboarding/* calls until POST /api/onboarding/complete exchanges it for a real access token.

Step 4 — Continue onboarding

Use PATCH /api/onboarding/session and POST /api/onboarding/complete as documented in mobile-onboarding-guide.md. No verification gate at /complete.

Error responses

CodeWhenUX
429Too many /send calls in window"Try again in N minutes"
400 INVALID_OTPWrong / expired code"Code incorrect or expired. Request a new one."
400 MAX_ATTEMPTS_EXCEEDED3 wrong codesForce user to request a new code.
409 ACCOUNT_EXISTSEmail already registeredRoute to login screen.

2. Password Reset (three steps)

Three screens, three endpoints. The OTP is verified on screen 2 and exchanged for a single-use resetToken; the new password is collected on screen 3 and submitted with that token. This matches the wireframe and lets mobile show clear per-screen errors.

Step 1 — Request a reset code (email screen)

POST /api/auth/forgot-password
{ "email": "user@example.com" }

Response 200 always (anti-enumeration):

{
"success": true,
"message": "If an account exists for that email, a reset code has been sent."
}

UX: show the same confirmation regardless. Do not reveal whether the email is registered.

An unregistered email still advances to the OTP screen. That is the design, not a defect. The endpoint cannot say "no account found" without handing an attacker a registered/not-registered oracle for any address they type — on a platform whose accounts belong to children, that answer is a list of which kids are here. The dead end happens one screen later: step 2 returns 400 INVALID_OTP ("Invalid or expired verification code."), because no code was ever sent. Ticket QA-filed as a bug on 2026-09-01; closed by-design — see DECISIONS 2026-09-01.

Make the screen-1 copy carry its own uncertainty so the dead end isn't a surprise. Say "If an account exists for that email, we've sent a 6-digit code." — not "We sent a code to ", which promises an email that may never arrive. Give screen 2 a visible "Use a different email" back affordance so a user who mistyped can correct it without restarting the flow; that is the recovery path the generic response takes away.

Step 2 — Verify the code (code screen)

POST /api/auth/verify-reset-code
{
"email": "user@example.com",
"code": "123456"
}

Response 200:

{
"success": true,
"data": {
"resetToken": "MFRkX...base64url",
"expiresIn": 600
}
}
  • resetToken is single-use, 10-minute TTL. Stored in SecureStore (or in-memory if the new-password screen is the very next screen).
  • The OTP is consumed by this call — re-submitting the same code returns INVALID_OTP.

Step 3 — Submit new password (new-password screen)

POST /api/auth/reset-password
{
"resetToken": "MFRkX...base64url",
"newPassword": "NewSecurePass1"
}

Response 200:

{
"success": true,
"message": "Password reset. Please log in with your new password."
}

On success the server revokes all refresh tokens + sessions for the user. The user must re-login on every device — including the one that just reset.

Error responses

CodeWhenUX
400 INVALID_OTPBad code, expired, or unknown email"Code incorrect or expired."
400 MAX_ATTEMPTS_EXCEEDED3 wrong attempts on the OTP"Request a new code."
400 RESET_TOKEN_INVALIDresetToken expired, used, or unknown"Reset session expired. Start over."
400 (zod)Weak new passwordInline field errors.
429Per-IP rate limit hitBack off — wait an hour.

Password rules (same as register): min 8 chars, ≥1 uppercase, ≥1 lowercase, ≥1 digit.

Mobile flow after success

  1. Clear SecureStore tokens.
  2. Navigate to login screen.
  3. Pre-fill email; user enters new password.
  4. Normal POST /api/auth/login.

3. Reference Snippets

Helpers

async function sendCode(
identifier: string,
purpose: "email_verification" | "password_reset",
) {
const { data } = await axios.post(
`${API_URL}/api/verification/send`,
{ channel: "email", identifier, purpose },
{ headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
);
return data.data.expiresIn; // seconds
}

// Onboarding popup verify (purely client-side UX — server has no record).
async function confirmEmailCode(identifier: string, code: string) {
await axios.post(
`${API_URL}/api/verification/confirm`,
{ identifier, code, purpose: "email_verification" },
{ headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
);
}

async function startOnboardingSession(input: {
email: string;
password: string;
code?: string;
}) {
const { data } = await axios.post(
`${API_URL}/api/onboarding/session`,
input,
{ headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
);
return data.data.onboardingToken;
}

// Password reset — 3-step.
async function forgotPassword(email: string) {
await axios.post(
`${API_URL}/api/auth/forgot-password`,
{ email },
{ headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
);
}

async function verifyResetCode(email: string, code: string) {
const { data } = await axios.post(
`${API_URL}/api/auth/verify-reset-code`,
{ email, code },
{ headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
);
return data.data.resetToken as string; // ttl 600s
}

async function resetPassword(resetToken: string, newPassword: string) {
await axios.post(
`${API_URL}/api/auth/reset-password`,
{ resetToken, newPassword },
{ headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
);
}

curl

# 1. Trigger reset code
curl -X POST http://localhost:3001/api/auth/forgot-password \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"email":"user@example.com"}'

# 2. Verify code → resetToken
curl -X POST http://localhost:3001/api/auth/verify-reset-code \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"email":"user@example.com","code":"123456"}'

# 3. Submit new password
curl -X POST http://localhost:3001/api/auth/reset-password \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"resetToken":"<from step 2>","newPassword":"NewSecurePass1"}'

4. Gotchas

  • OTP storage: codes are SHA-256 hashed at rest, 5-min TTL, 3 attempts. After 3 wrong attempts the code is wiped — user must request a new one.
  • Generation rate limit: 5 codes per (identifier, purpose) per 15 min. 6th /send (or /forgot-password) silently no-ops to prevent abuse.
  • Per-IP rate limits:
    • POST /api/auth/forgot-password — 5 / hour / IP.
    • POST /api/auth/verify-reset-code — 10 / hour / IP.
    • POST /api/auth/reset-password — 10 / hour / IP.
  • resetToken is single-use. It's deleted from the server's store the moment /reset-password reads it — even if the subsequent password update fails. On any error after step 2, re-run the full 3-step flow.
  • resetToken expiry = 10 minutes. Storing it persistently is fine, but it'll fail with RESET_TOKEN_INVALID once expired.
  • Email delivery requires RESEND_API_KEY + ADMIN_EMAIL in the API env. Without them codes log to API stdout (ConsoleChannel fallback). Useful for local dev — surface the code from container logs.
  • Resend requires a verified sender domain before sending to arbitrary recipients. Until verified, ADMIN_EMAIL must equal your Resend signup email and you can only email yourself.
  • Onboarding verification is advisory at the gate but persisted in the DB when it happens. Popup-verify (/verification/confirm) stamps User.emailVerifiedAt / OnboardingSession.emailVerifiedAt and writes a 10-minute store flag that /onboarding/session consumes on create. /complete does not check emailVerifiedAt, so an account still goes live if verification was skipped — but later trust features (feed weighting, parental flow) can read the column reliably.
  • Phone signup is not live. PHONE_VERIFICATION purpose is not in the unauthenticated-allowed set on /api/verification/send, and no real SMS adapter is registered. Email is the only working channel.
  • Identifier must match byte-for-byte between /verification/send, /verification/confirm, and any onboarding /session code payload. The OTP store key is otp:email_verification:<identifier>User@x.comuser@x.com. Normalize (lowercase + trim) on the client before every call.
⤓ Download .md