Sign-up, OAuth & Invitations

Password Reset

RESTHeart Ulabase

The password reset flow is split into two endpoints: one to request a reset link and one to apply the new password. The flow is designed to prevent account enumeration: the first endpoint always returns 202 Accepted regardless of whether the email exists.

The same two steps also recover an account that never completed email verification: the reset link lets the owner set a password of their own and verifies the address at the same time (since 9.13.0).

sequenceDiagram
  participant C as Client
  participant R as RESTHeart
  participant M as MongoDB
  participant E as Email

  Note over C,E: Step 1 — Request reset link
  C->>R: POST /auth/forgot-password {email}
  R->>M: lookup user (result not revealed)
  opt user found (verified or not)
    R->>M: generate + store reset token (TTL 1h)
    R->>E: password reset email
  end
  R-->>C: 202 Accepted (always)

  Note over C,M: Step 2 — Apply new password
  C->>R: PATCH /auth/reset-password {email, token, password}
  Note over R: constant-time token compare + TTL check
  Note over R: zxcvbn password strength check
  R->>M: hash + save password, unset reset token
  opt account not verified yet
    R->>M: set roles → default-role, unset verification token
  end
  M-->>R: OK
  R-->>C: 200 OK (JWT via cookie or body, per delivery)

POST /auth/forgot-password — publicly accessible ($unauthenticated).

Request

POST /auth/forgot-password
Content-Type: application/json

{ "email": "alice@example.com" }

Server-side steps

  1. Validate the email format.

  2. Look up the user by email (result is not revealed to the caller).

  3. If an email was sent to this address less than resend-cooldown-seconds ago (default 300, see accountsEmailPolicy), stop here and send nothing. The last sending time is the more recent of passwordResetCreatedAt and emailVerificationCreatedAt, so the verification email sent at registration counts too. Since 9.12.2: before, every call sent an email.

  4. If the user exists, verified or not:

    • Generate a cryptographically random passwordResetToken (256-bit) and record passwordResetCreatedAt.

    • Send a reset email containing a one-time link:

      {frontendUrl}/auth/reset-password?email=alice@example.com&token=<passwordResetToken>
    • TTL: 1 hour.

  5. Always return 202 Accepted — even if the email is unknown.

An account that has not completed email verification yet (roles = ["$unauthenticated"]) gets the same reset link. Following it proves ownership of the address, as the verification link does, and the reset makes the owner choose the password. This is also the owner’s only self-service way to recover a lost or expired verification email, since registration rejects the address as already taken.

Before 9.13.0 the verification link was re-sent instead. That link activated the account with the password chosen at registration, and since anyone can register any address, that password could have been chosen by someone else (GHSA-m7m7-gjxg-cjmm).

Note
Responding 202 unconditionally, also when the account does not exist or the cooldown stops the email, prevents an attacker from enumerating registered email addresses or their verification status by observing response differences.

Response

{ "message": "If that address is registered, a reset link has been sent." }

Apply the new password

PATCH /auth/reset-password — publicly accessible ($unauthenticated).

Request

PATCH /auth/reset-password
Content-Type: application/json

{
  "email":              "alice@example.com",
  "passwordResetToken": "<token from email>",
  "password":           "new-correct-horse-battery"
}

Server-side steps

  1. Validate all required fields.

  2. Find user by email.

  3. Compare passwordResetToken using constant-time comparison.

  4. Check passwordResetCreatedAt + TTL (passwordResetTokenTtlHours, default 1 hour).

  5. Enforce password strength (zxcvbn score ≥ minimumPasswordStrength).

  6. Update the user:

    {
      "$set":   { "password": "<bcrypt hash>" },
      "$unset": { "passwordResetToken": "", "passwordResetCreatedAt": "" }
    }
  7. If the account had not completed email verification (roles = ["$unauthenticated"]), verify it now: $set roles to the configured default-role and $unset emailVerificationToken and emailVerificationCreatedAt, as GET /auth/verify does. Since 9.13.0: before, the reset was refused with 400 Account not verified.

  8. Auto-login: issue a fresh JWT and deliver it per the optional delivery query parameter — cookie (default; sets the rh_auth cookie) or body (since 9.6.0; returns access_token/token_type/expires_in in the response body plus the Auth-Token header, no cookie).

  9. Return 200 OK.

Error responses

Status Reason

400

Missing fields; password too weak; token not found or expired

Note
The token is one-shot: it is $unset on first successful use, so the link cannot be replayed.