Repository navigation
21 Forgot Password and Email Architecture
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.
- 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.
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."
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"]
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:
- Set
used_aton every earlier unused token for that user, so only the newest link works. - Create a token with
secrets.token_urlsafe(32)and storesha256(token)inpassword_reset_tokenswithexpires_at = now + 30 min. - Write an audit row
password_reset_requested. - Queue
_deliver()as a FastAPIBackgroundTask.
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.
<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.
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/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.
_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.
| 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)
|
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.
- 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_logschecks this. - A successful reset revokes every session, including any an attacker may hold.
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.
Authentication and Session Security · Web Application Architecture · Android Application Architecture · Database and Data Model · Security and Privacy Model · Deployment Architecture
Overview
Platform
Identity and access
Medical records
- 10 Document Ingestion
- 11 OCR and Human Verification
- 13 Medicines and Administration
- 14 Vaccination Model
- 15 Labs, Growth, Longitudinal
- 16 Illness and Lifetime Health
Daily care
Output and sharing
Security
Engineering
- 26 Testing and Validation
- 27 Deployment and CI/CD
- 28 Technology Stack
- 29 Design Decisions
- 30 Limitations
- 31 Roadmap