Problem
There is currently no way to recover an account if a user forgets their password.
The only password-change endpoint is PATCH /api/user/password (app/api/user/password/route.ts), and it requires the user to already be logged in and supply their current password — that's a "change my password" flow, not a "I forgot my password" flow. There's no /forgot-password page, no reset-token generation, nothing.
Current auth architecture (for context)
- Custom JWT auth (not NextAuth) —
lib/auth/jwt.ts signs/verifies tokens, AUTH_SECRET env var
- Users are bcrypt-hashed and stored via Prisma/Postgres —
lib/auth/users.ts, prisma/schema.prisma
- The
User model already has a proven pattern for this exact kind of thing: verificationToken / verificationTokenExpiresAt, used for email verification (generated in createUser(), consumed in GET /api/auth/verify-email)
- Login/signup rate limiting already exists and can be reused — see
createRateLimiter() in lib/auth/rate-limit.ts, used in app/api/auth/login/route.ts and signup/route.ts
Proposed implementation
- Schema: add
resetToken / resetTokenExpiresAt fields to the User model in prisma/schema.prisma, mirroring the existing verificationToken pair. New migration.
POST /api/auth/forgot-password — accepts an email address.
- Always returns a generic success response regardless of whether the email exists (don't leak account existence).
- If the account exists, generate a short-lived token (suggest 1 hour, shorter than the 24h email-verification window since this is more security-sensitive) and store it.
- Send a reset-link email:
${origin}/reset-password?token=<resetToken>.
- Rate-limit this endpoint (same pattern as login/signup) — it's an obvious abuse target otherwise.
POST /api/auth/reset-password — accepts { token, newPassword }.
- Validates the token exists and hasn't expired.
- Hashes and sets the new password, clears the reset token so it can't be reused.
- Should invalidate existing sessions if practical (at minimum, don't silently leave old JWTs valid forever — check what
lib/auth/jwt.ts currently supports here and note the limitation if a full fix is out of scope).
- UI: two new pages matching the current light/dark brand (see
app/login/page.tsx and app/signup/page.tsx for the card-soft / btn-pill / form patterns to follow):
/forgot-password — email input, "Send reset link" button, success state
/reset-password?token=... — new-password + confirm fields, same password-strength UX as signup, success state redirecting to /login
- Add a "Forgot password?" link on
/login under the password field.
Dependency
This needs a working email-send path to actually deliver the reset link. If #860 (verification emails are never sent) isn't fixed yet, either fix that first or implement the same Resend-via-fetch pattern described there as part of this issue — don't duplicate a second, different email-sending mechanism.
Acceptance criteria
Environment variables
Same as the email-verification issue: you don't need real credentials to build the flow (log emails to console in development). Only request RESEND_API_KEY / RESEND_FROM_EMAIL from a maintainer if you need to verify a real send, and only once you're at that stage.
Suggested labels: auth, feature, backend, complexity:MEDIUM
Problem
There is currently no way to recover an account if a user forgets their password.
The only password-change endpoint is
PATCH /api/user/password(app/api/user/password/route.ts), and it requires the user to already be logged in and supply their current password — that's a "change my password" flow, not a "I forgot my password" flow. There's no/forgot-passwordpage, no reset-token generation, nothing.Current auth architecture (for context)
lib/auth/jwt.tssigns/verifies tokens,AUTH_SECRETenv varlib/auth/users.ts,prisma/schema.prismaUsermodel already has a proven pattern for this exact kind of thing:verificationToken/verificationTokenExpiresAt, used for email verification (generated increateUser(), consumed inGET /api/auth/verify-email)createRateLimiter()inlib/auth/rate-limit.ts, used inapp/api/auth/login/route.tsandsignup/route.tsProposed implementation
resetToken/resetTokenExpiresAtfields to theUsermodel inprisma/schema.prisma, mirroring the existingverificationTokenpair. New migration.POST /api/auth/forgot-password— accepts an email address.${origin}/reset-password?token=<resetToken>.POST /api/auth/reset-password— accepts{ token, newPassword }.lib/auth/jwt.tscurrently supports here and note the limitation if a full fix is out of scope).app/login/page.tsxandapp/signup/page.tsxfor thecard-soft/btn-pill/ form patterns to follow):/forgot-password— email input, "Send reset link" button, success state/reset-password?token=...— new-password + confirm fields, same password-strength UX as signup, success state redirecting to/login/loginunder the password field.Dependency
This needs a working email-send path to actually deliver the reset link. If #860 (verification emails are never sent) isn't fixed yet, either fix that first or implement the same Resend-via-
fetchpattern described there as part of this issue — don't duplicate a second, different email-sending mechanism.Acceptance criteria
/forgot-passwordpage exists, submits to a working endpoint, shows a generic success message either way/reset-password?token=.../reset-passwordactually changes the password and the user can log in with itEnvironment variables
Same as the email-verification issue: you don't need real credentials to build the flow (log emails to console in development). Only request
RESEND_API_KEY/RESEND_FROM_EMAILfrom a maintainer if you need to verify a real send, and only once you're at that stage.Suggested labels:
auth,feature,backend,complexity:MEDIUM