Skip to content

[Auth] Add forgot-password / reset-password flow #861

Description

@Ogstevyn

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

  1. Schema: add resetToken / resetTokenExpiresAt fields to the User model in prisma/schema.prisma, mirroring the existing verificationToken pair. New migration.
  2. 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.
  3. 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).
  4. 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
  5. 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

  • /forgot-password page exists, submits to a working endpoint, shows a generic success message either way
  • Requesting a reset for a non-existent email does not reveal that (same response as a real one)
  • Reset email contains a working link to /reset-password?token=...
  • Submitting a valid token + new password on /reset-password actually changes the password and the user can log in with it
  • Expired or already-used tokens are rejected with a clear error
  • The forgot-password endpoint is rate-limited
  • Tests covering token expiry, token reuse, and the happy path

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions