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.
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.
userId. Asking again replaces the code./auth/forgot always answers {ok:true}, account or not.attempts = 0.void send().catch(log)).devCode) so dev and browser flows
work without an inbox — and write a test asserting production does not.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:
<. EMAIL_FROM=Name <noreply@…>
unquoted is a shell redirect; a deploy that sources .env dies mid-deploy.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.+tag of your own
inbox. Confirm: in Inbox not Spam, from noreply@<domain>,
signed-by: <domain>. Delete the account afterwards./auth/forgot returns no devCode.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.