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.
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
codeto/api/onboarding/session. - Skip the popup entirely.
/api/onboarding/sessionacceptsemail+passwordalone;/completesucceeds.
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
| Code | When | UX |
|---|---|---|
| 429 | Too many /send calls in window | "Try again in N minutes" |
400 INVALID_OTP | Wrong / expired code | "Code incorrect or expired. Request a new one." |
400 MAX_ATTEMPTS_EXCEEDED | 3 wrong codes | Force user to request a new code. |
409 ACCOUNT_EXISTS | Email already registered | Route 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.
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
}
}
resetTokenis 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
| Code | When | UX |
|---|---|---|
400 INVALID_OTP | Bad code, expired, or unknown email | "Code incorrect or expired." |
400 MAX_ATTEMPTS_EXCEEDED | 3 wrong attempts on the OTP | "Request a new code." |
400 RESET_TOKEN_INVALID | resetToken expired, used, or unknown | "Reset session expired. Start over." |
| 400 (zod) | Weak new password | Inline field errors. |
| 429 | Per-IP rate limit hit | Back off — wait an hour. |
Password rules (same as register): min 8 chars, ≥1 uppercase, ≥1 lowercase, ≥1 digit.
Mobile flow after success
- Clear SecureStore tokens.
- Navigate to login screen.
- Pre-fill email; user enters new password.
- 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-passwordreads 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_INVALIDonce expired. - Email delivery requires
RESEND_API_KEY+ADMIN_EMAILin the API env. Without them codes log to API stdout (ConsoleChannelfallback). Useful for local dev — surface the code from container logs. - Resend requires a verified sender domain before sending to arbitrary
recipients. Until verified,
ADMIN_EMAILmust 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) stampsUser.emailVerifiedAt/OnboardingSession.emailVerifiedAtand writes a 10-minute store flag that/onboarding/sessionconsumes on create./completedoes not checkemailVerifiedAt, 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_VERIFICATIONpurpose 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/sessioncodepayload. The OTP store key isotp:email_verification:<identifier>—User@x.com≠user@x.com. Normalize (lowercase + trim) on the client before every call.