--- name: add-password-reset description: Use when a web app has email + password login but no way back in — adding "forgot password", a reset flow, or letting someone who cannot log in prove they own their email address. Also use when choosing how a reset is delivered, when an existing reset leaves the user logged out in the wrong browser (installed PWA vs Safari), 404s on the reset screen, reveals which addresses have accounts, or never reaches the inbox. --- # Add password reset Built twice in two different apps. Almost none of the *code* carried over — different hashes, CSS, test harnesses. All of the *decisions* did. This skill is those decisions. The full step-by-step with code is in `workflows/password-reset.md`; email setup is `workflows/email-sending.md`. Most of what makes a reset safe you already know: store a hash not the secret, answer the same way whether or not the account exists, rate-limit, end every session on success. Do all of that. This skill is the part that is not obvious, and the part that only bites in production. ## Send a code. Never a link. **Default to a six-digit code typed back into the app.** This is the opposite of the usual answer, so the reason matters: Apps that people install to the home screen (PWAs) run in their own storage. On iOS a home-screen web app has **its own WebKit storage container, separate from Safari's**. A link tapped in Mail opens Safari, the new session cookie lands there, and the user returns to the app icon *still logged out*. Gmail's in-app browser splits the same way. A code is typed into the app that is asking for it, so it logs in **there**. | The argument for a link | Why it loses here | |---|---| | "One tap is better UX" | One tap into the wrong browser is a dead end. | | "Codes get mistyped" | A typo costs a retype. The split costs being locked out. | | "It's the standard" | The standard assumes a browser, not an installed app. | | "Desktop users prefer it" | Then add a link *beside* the code — never instead. | | "Same origin, so the PWA is fine" | **Wrong — the most common mistake.** iOS gives the installed app its own container *for the same origin*. | | "The link will open the installed app" | Android sometimes; iOS no. Don't design around it. | **Red flag:** if you catch yourself writing that a reset link is fine "because it is the same origin", "because the PWA is installed", or "because it opens the app" — stop and re-read this section. Code-only is also **less code**: no token route, no expired-link page, no already-used state, no reset page that can 404. ## Required properties (the checklist) - **One row per user**, keyed on `userId`. Asking again replaces the code. - **Hash the code** with the app's own password hash (argon2, scrypt…). Six digits is tiny entropy; it needs the slow hash *and* the attempt cap. - **30 minutes**, **5 wrong tries**, **3 codes per hour per address.** - **`/auth/forgot` always answers `{ok:true}`**, account or not. - **Hash the code before looking the address up.** A slow hash skipped for an unknown address is a measurable timing difference. On the reset side, verify against a dummy hash when there is no row. - **One generic failure message** for unknown address, no code, expired, wrong digits, out of tries: *"That code is wrong or has expired. Ask for a new one."* - **Spend the attempt atomically** (conditional increment) before the slow verify, so parallel guesses can't all read `attempts = 0`. - **Single use:** whichever request deletes the row owns the reset. - **Send the mail after the reply** (`void send().catch(log)`). - **On success: delete every session for that user, then start a new one here.** Don't bounce them to the login screen. - **Outside production, return the code** (`devCode`) so dev and browser flows work without an inbox — **and write a test asserting production does not.** ## The email path Check first whether the server can send mail at all (many VPS providers block ports 25/465/587). If not, use an HTTPS email API from an authenticated domain — see `workflows/email-sending.md`. Two things that cost an hour each: - **Quote any env value containing `<`.** `EMAIL_FROM=Name ` unquoted is a shell redirect; a deploy that sources `.env` dies mid-deploy. - If the provider dashboard login fails, don't fight it — domains and senders are all manageable through the provider's API with the key. ## Traps that only show in production - **Register the new route server-side.** A client route the server doesn't know is served only via in-app navigation; refresh, bookmark or a pasted link gets a 404. Find the SPA route list / `try_files` **before** writing the page. - **`curl` on an SPA route may 404 without `-H 'Accept: text/html'`.** Test `/login` the same way before concluding anything. - **Never point the reset browser flow at production** — it changes a password. Make the flow refuse the live origin. - **Tap targets ≥ 40 px** on the new screen. ## Verify before calling it done 1. Unit-test the pure rules (code generation, expiry, attempt cap, hourly allowance) in whatever shape the repo tests. 2. A browser flow at phone width, **local dev only**: the login link carries the typed address over, wrong code refused without losing the screen, right code logs in, old password dead, new one works, code refused the second time. 3. **Then once on production** with a throwaway account on a `+tag` of your own inbox. Confirm: in Inbox not Spam, from `noreply@`, `signed-by: `. Delete the account afterwards. 4. Confirm the live `/auth/forgot` returns **no** `devCode`. ## What varies, so don't copy code The hash (argon2 vs scrypt), the CSS system, the validation layer (zod vs JSON schema), whether the repo has a DB test harness, and where routes are registered. Read the target repo first; carry the decisions above, not files.