--- title: Password Reset Flow tags: [flow, auth, password-reset, email] related_models: ["[[User]]"] related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code", "POST /api/auth/reset-password"] --- > **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report) > [!caution] Audit note — last reviewed 2026-05-29 > Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches. # Password Reset Flow Self-service password recovery. There are **two separate reset endpoints** with different security characteristics: | Endpoint | Mechanism | Password complexity enforced? | |---|---|---| | `POST /api/auth/reset-password-with-code` | 6-digit emailed code | **No** — no validation middleware | | `POST /api/auth/reset-password` | Token-based (link in email) | **Yes** — `passwordResetValidation` requires uppercase + lowercase + digit | The primary UI-driven path uses the **code-based** endpoint. The token-based endpoint is a legacy/alternative variant. ## Actors - **User** who has forgotten their password. - **Frontend** — `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx` (request) and `jwt-update-password-view.tsx` (submit new password). - **Backend** — `AuthController.requestPasswordReset` and `AuthController.resetPasswordWithCode` in `backend/src/services/auth/authController.ts`. - **MongoDB** — `User` collection (`passwordResetCode`, `passwordResetCodeExpires`, `refreshTokens`). - **Email service** — `emailService.sendPasswordResetCodeEmail`. ## Preconditions - The account exists and `status === "active"` (deleted accounts are silently treated as non-existent). - The user has access to the email inbox associated with the account. - A 6-digit code is valid for **1 hour** (`authController.ts:556`). ## Step-by-step narrative 1. User clicks "Forgot password?" on the sign-in page and lands at `/auth/jwt/reset-password`. 2. User enters their email and submits. 3. Frontend POSTs `POST /api/auth/request-password-reset { email }`. 4. Backend `authController.requestPasswordReset` (`:542-574`): - `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**. - Generates a **6-digit** code via `authService.generateVerificationCode()` (`Math.floor(100000 + Math.random() * 900000)`). - Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user. - Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`. 5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome. 6. User receives the email and enters the code + new password on `/auth/jwt/update-password`. 7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`. 8. Backend `authController.resetPasswordWithCode` (`:611-657`): - Validates code format `/^\d{6}$/` — codes of any other length will **always fail** here. - `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`. - Hashes the new password with bcrypt cost 12. **No password complexity validation is applied** — weak passwords such as `123456` or `aaaaaa` are accepted without error. - Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions. - Saves. 9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login. ## Sequence diagram ```mermaid sequenceDiagram autonumber actor U as User participant FE as Frontend participant BE as Backend participant DB as MongoDB participant MAIL as Email Service U->>FE: Click "Forgot password", enter email FE->>BE: POST /api/auth/request-password-reset { email } BE->>DB: User.findOne({ email, status: "active" }) alt user found BE->>BE: code = generateVerificationCode() [6 digits] BE->>DB: user.passwordResetCode = code\nexpires = +1h BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code) MAIL-->>U: Email with 6-digit code end BE-->>FE: 200 "if account exists, code sent" U->>FE: Enter code + new password FE->>BE: POST /api/auth/reset-password-with-code { email, code, password } BE->>BE: isValidVerificationCode(code) [/^\d{6}$/] BE->>DB: User.findOne({ email, code, expires>now }) BE->>BE: bcrypt.hash(password, 12) [no complexity check] BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields BE-->>FE: 200 "Password reset successfully" FE-->>U: Redirect /auth/jwt/sign-in ``` ## API calls | Method | Endpoint | Source | Notes | |---|---|---|---| | `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` | Sends 6-digit code by email | | `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` | Code-based; **no complexity validation** | | `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` | Token-based variant; enforces complexity via `passwordResetValidation` | ## Two-endpoint comparison > [!important] Code-based vs token-based reset endpoints > > **`POST /api/auth/reset-password-with-code`** (primary UI path) > - Uses a 6-digit numeric code delivered by email. > - `isValidVerificationCode()` validates with `/^\d{6}$/`. > - Has **no password complexity middleware**. Any string is accepted as the new password. > > **`POST /api/auth/reset-password`** (legacy token-based path) > - Uses a URL token (link in email) rather than a short code. > - Enforces password complexity via `passwordResetValidation` middleware (requires uppercase, lowercase, and a digit). > > The two endpoints provide inconsistent security guarantees. Users who reset via the code flow can set a weak password that would be rejected by the token flow. ## Database writes - **`users` collection**: on request, sets `passwordResetCode` + `passwordResetCodeExpires`. On submit, replaces `password`, clears reset fields, and empties `refreshTokens`. ## Socket events emitted - None. ## Side effects - **Email**: one transactional message containing the 6-digit code. - **Server-side log**: `authController.ts:559` `console.log` includes the generated code in plain text — same hardening note as [[Registration Flow]]. - **Session invalidation**: All refresh tokens cleared → all devices forced to re-login after password change. Access tokens still valid until expiry (typically minutes). ## Error / edge cases - **Unknown email** → always `200`, generic message. No enumeration. - **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup. - **Expired code** (>1h) → `400 Invalid or expired reset code`. - **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated. - **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned). - **Email delivery failure** → response still `200`; user can request again. - **Access tokens still valid post-reset** → unavoidable with stateless JWT; mitigated by short TTL. Critical operations should re-verify password. > [!warning] Plaintext code in logs > Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`. ## Known issues summary | Issue | Severity | Details | |---|---|---| | No password complexity on code-based reset | Security gap | `POST /api/auth/reset-password-with-code` has no complexity middleware; weak passwords accepted | | Inconsistent complexity between reset endpoints | Security gap | Token-based reset enforces complexity; code-based reset does not | ## Linked flows - [[Authentication Flow]] — user re-signs-in after reset. - [[Registration Flow]] — same code-generation utility. ## Source files - Backend: `backend/src/services/auth/authController.ts:542-657` - Backend: `backend/src/services/email/emailService.ts` (`sendPasswordResetCodeEmail`) - Frontend: `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx` - Frontend: `frontend/src/auth/view/jwt/jwt-update-password-view.tsx` - Frontend: `frontend/src/auth/context/jwt/action.ts:181-200`, `:261-276`