Skip to content

21 Forgot Password and Email Architecture

Anubha Parashar edited this page Oct 10, 2026 · 1 revision

HealthBook AI (internal code name: BabyCare) lets a user reset a forgotten password through a link sent by email. Every request gets the same generic answer, so the response never reveals whether an account exists. The token is random, single use and valid for 30 minutes, and only its hash is stored. A completed reset signs out every session on the account. Email is sent through an HTTPS provider API, never through SMTP in production.

Responsibilities

  • Accept reset requests without revealing whether an account exists.
  • Issue a secure, hashed, expiring, single-use token and email the link.
  • Validate the token, set the new password and invalidate all reset tokens and sessions.
  • Send transactional mail through Brevo or Resend over HTTPS, with SMTP for local development only.

Inputs and outputs

Endpoint (prefix /api/auth/password-reset) Input Output
POST /request {email} Always 202 with {message: GENERIC}
POST /check {token} {valid: true, minutes_valid: 30}, or 400 with the invalid-link message
POST /confirm {token, password} (12–200 chars) {ok, signed_out_sessions, message}, or 400

The generic message is: "If an account exists for this email, a reset link has been sent. The link is valid for 30 minutes."

Mechanism

flowchart TD
  FP["User opens Forgot Password<br/>web password.js or mobile ForgotPassword.tsx"] --> REQ["POST /api/auth/password-reset/request"]
  REQ --> GEN["Generic 202 response<br/>same text for every email"]
  REQ --> CHK{"Account exists and<br/>per-email limit 3 per hour ok?"}
  CHK -->|"no"| NOOP["Nothing sent, nothing stored"]
  CHK -->|"yes"| OLD["Mark older unused tokens used"]
  OLD --> TOK["secrets.token_urlsafe 32 bytes"]
  TOK --> HASH["Store SHA-256 hash only<br/>expires_at = now plus 30 min"]
  HASH --> BG["Background task builds link<br/>web origin plus URL fragment<br/>reset-password equals token"]
  BG --> BREVO["Brevo HTTPS API<br/>or Resend"]
  BREVO --> MAIL["Email in user inbox"]
  MAIL --> PAGE["Reset page on web origin<br/>token read from URL fragment"]
  PAGE --> VAL["POST check, then confirm<br/>hash lookup, unused, not expired"]
  VAL -->|"invalid"| BAD["400 link invalid or expired"]
  VAL -->|"valid"| NEWPW["New salt and PBKDF2 hash"]
  NEWPW --> USED["Mark this and all other tokens used"]
  USED --> KILL["Delete every session of the account"]
  KILL --> AUD["Audit password_reset_completed"]
Loading

Request

request_reset() looks up the normalised email. Only if the user exists and the per-email limit allows it (rate_allowed('pwreset:' + sha256(email)[:32], 3, 3600)) does it:

  1. Set used_at on every earlier unused token for that user, so only the newest link works.
  2. Create a token with secrets.token_urlsafe(32) and store sha256(token) in password_reset_tokens with expires_at = now + 30 min.
  3. Write an audit row password_reset_requested.
  4. Queue _deliver() as a FastAPI BackgroundTask.

Because the email is sent in the background, a registered address does not make the response slower. Unknown emails, rate-limited emails and real requests all get the same status and body. The route is also under /api/auth/, so it shares the 30-requests-per-minute-per-IP limit described in Authentication and Session Security.

Link format

<web origin>/#reset-password=<token>

The web origin is BABYCARE_PUBLIC_WEB_ORIGIN, falling back to the first BABYCARE_CORS_ORIGINS entry and then http://127.0.0.1:8000. In a deployment it is a placeholder such as https://<project>.web.app. The token is in the URL fragment, which browsers never send to a server, so it cannot appear in hosting or API access logs.

Reset page (web)

web/password.js checks location.hash against ^#reset-password=([A-Za-z0-9_-]{20,200})$. It stores the token in S.pwReset, then immediately removes the fragment from the address bar with history.replaceState. It calls /check so it can say up front whether the link still works, shows a two-field new-password form (the fields must match and have at least 12 characters), and calls /confirm. After success it shows "every device that was signed in to this account has been signed out". The same file adds the "Forgot password?" button under the sign-in form.

Mobile

mobile/ForgotPassword.tsx calls only /request. The user opens the emailed link on the phone or any computer, sets the password on the web origin, then signs in in the app. Nothing about the token or account is stored on the phone.

Confirm

_valid_token() finds the row by hash and requires used_at IS NULL and expires_at in the future. confirm_reset() then sets a new salt and hash with create_password(), marks this token and any other open tokens as used, deletes all rows in sessions for the user, writes the audit row password_reset_completed and returns how many sessions were signed out. Profiles and medical records are not touched.

Token properties

Property Value
Entropy 32 random bytes (secrets.token_urlsafe(32))
Stored token_hash (SHA-256, unique). The raw token is never stored
Lifetime TOKEN_MINUTES = 30
Use Single use. A newer request makes older links invalid
Schema Migration 0010: password_reset_tokens(id, user_id → users ON DELETE CASCADE, token_hash unique, created_at, expires_at, used_at)

Email transport (api/mailer.py)

BABYCARE_EMAIL_PROVIDER Transport Notes
brevo POST https://api.brevo.com/v3/smtp/email with header api-key Needs BABYCARE_EMAIL_API_KEY and BABYCARE_EMAIL_FROM
resend POST https://api.resend.com/emails with a bearer key Same variables
smtp STARTTLS SMTP Local PC only. Free hosting blocks outbound SMTP ports
dev-outbox Appends to a local JSONL file Refused in production
empty Not sent Logs "no email provider configured"

send() never raises. It returns True only when the provider accepts the message. Failures are logged by HTTP status or exception class name only, never with the recipient or body. Tests replace mailer.OUTBOX with a list to capture messages.

When email is not configured, the request endpoint still returns the same generic 202 and still creates the token, but no email arrives, so the user cannot complete a reset. Operators can see this in GET /api/internal/status (protected by the scheduler key), which reports email_provider, email_configured and reset_link_minutes.

Security boundaries

  • Enumeration resistance: same status, body and (because sending is in the background) timing for every email. The per-email limit applies silently.
  • The token never appears in server logs (it is in the fragment), the database (only the hash is stored) or mail logs. test_token_and_password_never_reach_the_logs checks this.
  • A successful reset revokes every session, including any an attacker may hold.

Implementation

api/password_reset.py, api/mailer.py, api/security.py (create_password), api/guardrails.py (rate_allowed), migrations/versions/0010_password_reset.py, web/password.js, mobile/ForgotPassword.tsx. Tests: tests/test_password_reset.py (full flow keeps data and signs out old sessions, unknown email gets the same answer, hashed and expiring and replaced tokens, weak passwords and garbage tokens, per-email limit, no secrets in logs, fragment link on the public origin) and tests/browser/password_reset.spec.js.

Related pages

Authentication and Session Security · Web Application Architecture · Android Application Architecture · Database and Data Model · Security and Privacy Model · Deployment Architecture

Clone this wiki locally