# Self-serve forgot-password — gap & recommended design **Assessment date:** 2026-08-08 **Verdict:** **Out of scope for a minimal safe reuse.** Document only until a dedicated reset feature is approved. **Mail infra:** Existing SMTP/`mail.Send` is enough; **do not** build a new mail stack. Confirm product copy/TTL/rate limits before implementing. Related: [status-and-gaps.md](status-and-gaps.md), [ux-backlog.md](ux-backlog.md) (P0-10 set-password), [live-auth-security.md](live-auth-security.md), [migration-readiness.md](migration-readiness.md). --- ## What exists today (not forgot-password) | Piece | Role | Why it cannot serve “I forgot my password” | |-------|------|--------------------------------------------| | `must_set_password` + `auth.SetPassword` | First password after migration / bootstrap | Update requires `must_set_password = true`; returns `ErrPasswordAlreadySet` otherwise | | `ReissueSetPasswordInvite` | Admin re-issue durable invite | Requires `must_set_password` + active membership; ineligible for accounts that already set a password | | `AcceptInvite` | Invite / first-password accept | Existing users with password must **verify** current password — does not reset | | HMAC `IssueSetPasswordToken` + `handleCompleteSetPassword` | Token complete for first-set | Calls `SetPassword` → same `must_set_password` gate | | Admin `POST …/admin/…` set-password email | Operator-only bulk/single send | AuthZ + eligibility same as above; login CTA points here (no public resend) — see P0-10 | | `mail.SetPasswordMessage` / `MigratedSetPasswordMessage` | Email copy + `/accept-invite` links | Templates/links are first-set oriented; reusable **patterns** only | | Login UI | `password_not_set` CTA → `/accept-invite` + admin Users | No “Forgot password?” link | **Operator workarounds today:** platform admin re-issues set-password for `must_set_password` users; local/dev `ForceSetPassword` / migrator `-set-password` for bootstrap. Neither is self-serve recovery for users who already know they had a password. --- ## Why a “small patch” is unsafe Reusing invite/HMAC set-password for established accounts would require relaxing `must_set_password` (or calling `ForceSetPassword` from a public token path). That collapses first-set and reset semantics, weakens single-use guarantees for HMAC tokens (no DB row until success), and risks account takeover if the public request endpoint is naively bolted on. Intentional product gap (Wave 8): **no public self-serve resend** for set-password (P0-10). --- ## Recommended design (when approved) Reuse **mail delivery and rate-limit patterns** from admin set-password; add a **separate** reset purpose. Do not overload `must_set_password` / `AcceptInvite`. ### API (sketch) 1. **`POST /api/auth/forgot-password`** `{ "email": "…" }` - Always return the same opaque success (anti-enumeration). - Rate-limit by IP + normalized email (mirror admin set-password limiters). - If active user with deliverable email: issue **reset** token (prefer durable hashed row, invite-style; HMAC-only only if single-use store is added). - Skip synthetic `@legacy.local` silently. - Send `ForgotPasswordMessage` (new; link to `/reset-password?token=…`, not accept-invite first-set mode). 2. **`POST /api/auth/reset-password`** `{ "token", "password" }` - Validate token; set new argon2id hash for that user **regardless of** `must_set_password` (dedicated `ResetPassword`, not `SetPassword` / not public `ForceSetPassword`). - Invalidate token; optionally revoke other sessions. - Clear `must_set_password` if still set. ### Web - Login: “Forgot password?” → `/forgot-password`. - `/forgot-password`: email form + generic confirmation copy. - `/reset-password`: password + confirm; strip `?token=` from URL like accept-invite. ### Security checklist - Constant-time / uniform responses and timing where practical. - Token TTL short (e.g. 1h); one-time consume. - CSRF on cookie-authenticated POSTs; public forgot/reset still need CSRF if under same cookie middleware. - No token in list APIs or HTML; prefer `#token=` / exchange code later ([live-auth-security.md](live-auth-security.md) residual). - Tests: happy path, expired/reuse, unknown email response shape, rate limit, inactive user, synthetic email. ### Explicitly out of this design - New mail provider, queue, or template engine (extend `internal/mail`). - Turning admin set-password into a public endpoint. - Logged-in “change password” (optional follow-up; separate from forgot). --- ## Implementation status | Item | Status | |------|--------| | Self-serve forgot / reset | **Implemented** (`041_password_reset_tokens`, `POST /api/auth/forgot-password`, `POST /api/auth/reset-password`, `/forgot-password` + `/reset-password`, i18n) | | Migration first-set + admin re-issue | Implemented (P0-10) | | Mail SMTP send path | Implemented; forgot-password reuses `mail.Send` + `EMAIL_DRY_RUN` / `ApplyDryRun` | **ASSUMPTION:** Opaque `{status:"ok"}` responses; TTL **1h**; IP 10/min + email 3/hour limiters; no session revoke on reset (optional follow-up).