A customer who has forgotten their password can recover access without contacting anyone.
A "Forgot password?" link on the login page
Entering an email sends a reset link
Following the link lets them set a new password
The old password stops working, and existing sessions are considered
Existing pieces this builds on
customer_tokens already has a kind discriminator, currently only verify_email — a password_reset kind fits the same table and expiry pattern
sendMail already degrades gracefully when SMTP is unconfigured, logging a warning and skipping the send
/verify-email already establishes the pattern for a token-carrying page
Design questions to resolve before implementing
Does requesting a reset for an unknown email report "no such account", or always report success? (Reporting failure enumerates which emails have accounts.)
Token lifetime, and whether requesting a new reset invalidates earlier ones
Whether completing a reset should terminate the customer's other active sessions
Whether a reset should also mark an unverified email as verified, since the customer has demonstrably received mail at that address
Rate limiting, so the endpoint cannot be used to send unlimited mail to an address
Blocks
Passkey login (#31 item 6) cannot ship without account recovery, so this comes first.
Split out of #31 (items 1 and 2).
## Goal
A customer who has forgotten their password can recover access without contacting anyone.
- A "Forgot password?" link on the login page
- Entering an email sends a reset link
- Following the link lets them set a new password
- The old password stops working, and existing sessions are considered
## Existing pieces this builds on
- `customer_tokens` already has a `kind` discriminator, currently only `verify_email` — a `password_reset` kind fits the same table and expiry pattern
- `sendMail` already degrades gracefully when SMTP is unconfigured, logging a warning and skipping the send
- `/verify-email` already establishes the pattern for a token-carrying page
## Design questions to resolve before implementing
- Does requesting a reset for an unknown email report "no such account", or always report success? (Reporting failure enumerates which emails have accounts.)
- Token lifetime, and whether requesting a new reset invalidates earlier ones
- Whether completing a reset should terminate the customer's other active sessions
- Whether a reset should also mark an unverified email as verified, since the customer has demonstrably received mail at that address
- Rate limiting, so the endpoint cannot be used to send unlimited mail to an address
## Blocks
Passkey login (#31 item 6) cannot ship without account recovery, so this comes first.
Branch feature/password-reset, one commit, not yet pushed.
Decisions taken
Question
Answer
Unknown address
Always report success. Identical response and wording either way
Sessions on reset
Terminate all of them, then sign in fresh on this device
Rate limiting
express-rate-limit — first rate limiting in the codebase
Token lifetime
1 hour, single use
Repeat requests
A new request supersedes any outstanding token
Unverified address
Marked verified on reset — receiving the mail is what verification proves
Flow
Forgot password? on the login page → /forgot-password → email → /reset-password?token=… → new password → signed in on /account.
Reuses customer_tokens with a password_reset kind alongside verify_email, and the existing sendMail, which still degrades gracefully when SMTP is unconfigured.
Two things worth flagging
The limiter had a real design bug, caught by the tests. It keys on caller and submitted address — keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. But /reset-password carries no address, so applying the same limiter there collapsed every customer completing a reset into one shared bucket of five per fifteen minutes. That endpoint is now deliberately unlimited: the token is 32 random bytes, single-use, hour-limited, and the expensive bcrypt hash only runs after the token matches, so invalid guesses cost one indexed lookup.
The e2e tests read the issued token straight from Postgres, not through a test-support endpoint. I started to add GET /api/test-support/latest-reset-token and stopped: an endpoint that returns a password-reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Reading it inside the test process keeps the capability with zero production surface.
Verification
Suite
Result
Backend unit
52 passed
Backend integration
89 passed (15 new)
Frontend e2e
53 passed (7 new), stable over two consecutive runs
tsc --noEmit backend / frontend
clean
npm run build
clean
Integration coverage includes the enumeration behavior, token supersession, cross-kind rejection (a verify_email token cannot stand in for a reset), replay, expiry, session eviction, and that a rejected short password does not burn the customer's only token.
Follow-up worth its own issue
/register still returns 409 an account with this email already exists, which makes registration an account-enumeration oracle and partly defeats the protection added here. Out of scope for this issue, but the two belong together.
## Implemented
Branch `feature/password-reset`, one commit, not yet pushed.
### Decisions taken
| Question | Answer |
| --- | --- |
| Unknown address | **Always report success.** Identical response and wording either way |
| Sessions on reset | **Terminate all of them**, then sign in fresh on this device |
| Rate limiting | **`express-rate-limit`** — first rate limiting in the codebase |
| Token lifetime | 1 hour, single use |
| Repeat requests | A new request supersedes any outstanding token |
| Unverified address | Marked verified on reset — receiving the mail is what verification proves |
### Flow
`Forgot password?` on the login page → `/forgot-password` → email → `/reset-password?token=…` → new password → signed in on `/account`.
Reuses `customer_tokens` with a `password_reset` kind alongside `verify_email`, and the existing `sendMail`, which still degrades gracefully when SMTP is unconfigured.
### Two things worth flagging
**The limiter had a real design bug, caught by the tests.** It keys on caller *and* submitted address — keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. But `/reset-password` carries no address, so applying the same limiter there collapsed every customer completing a reset into one shared bucket of five per fifteen minutes. That endpoint is now deliberately unlimited: the token is 32 random bytes, single-use, hour-limited, and the expensive bcrypt hash only runs *after* the token matches, so invalid guesses cost one indexed lookup.
**The e2e tests read the issued token straight from Postgres**, not through a test-support endpoint. I started to add `GET /api/test-support/latest-reset-token` and stopped: an endpoint that returns a password-reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Reading it inside the test process keeps the capability with zero production surface.
### Verification
| Suite | Result |
| --- | --- |
| Backend unit | 52 passed |
| Backend integration | 89 passed (15 new) |
| Frontend e2e | 53 passed (7 new), stable over two consecutive runs |
| `tsc --noEmit` backend / frontend | clean |
| `npm run build` | clean |
Integration coverage includes the enumeration behavior, token supersession, cross-kind rejection (a `verify_email` token cannot stand in for a reset), replay, expiry, session eviction, and that a rejected short password does **not** burn the customer's only token.
### Follow-up worth its own issue
`/register` still returns `409 an account with this email already exists`, which makes registration an account-enumeration oracle and partly defeats the protection added here. Out of scope for this issue, but the two belong together.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #31 (items 1 and 2).
Goal
A customer who has forgotten their password can recover access without contacting anyone.
Existing pieces this builds on
customer_tokensalready has akinddiscriminator, currently onlyverify_email— apassword_resetkind fits the same table and expiry patternsendMailalready degrades gracefully when SMTP is unconfigured, logging a warning and skipping the send/verify-emailalready establishes the pattern for a token-carrying pageDesign questions to resolve before implementing
Blocks
Passkey login (#31 item 6) cannot ship without account recovery, so this comes first.
Implemented
Branch
feature/password-reset, one commit, not yet pushed.Decisions taken
express-rate-limit— first rate limiting in the codebaseFlow
Forgot password?on the login page →/forgot-password→ email →/reset-password?token=…→ new password → signed in on/account.Reuses
customer_tokenswith apassword_resetkind alongsideverify_email, and the existingsendMail, which still degrades gracefully when SMTP is unconfigured.Two things worth flagging
The limiter had a real design bug, caught by the tests. It keys on caller and submitted address — keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. But
/reset-passwordcarries no address, so applying the same limiter there collapsed every customer completing a reset into one shared bucket of five per fifteen minutes. That endpoint is now deliberately unlimited: the token is 32 random bytes, single-use, hour-limited, and the expensive bcrypt hash only runs after the token matches, so invalid guesses cost one indexed lookup.The e2e tests read the issued token straight from Postgres, not through a test-support endpoint. I started to add
GET /api/test-support/latest-reset-tokenand stopped: an endpoint that returns a password-reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Reading it inside the test process keeps the capability with zero production surface.Verification
tsc --noEmitbackend / frontendnpm run buildIntegration coverage includes the enumeration behavior, token supersession, cross-kind rejection (a
verify_emailtoken cannot stand in for a reset), replay, expiry, session eviction, and that a rejected short password does not burn the customer's only token.Follow-up worth its own issue
/registerstill returns409 an account with this email already exists, which makes registration an account-enumeration oracle and partly defeats the protection added here. Out of scope for this issue, but the two belong together.