Skip to content

08 Authentication and Session Security

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

HealthBook AI (internal code name: BabyCare) uses email and password accounts with server-side sessions. The server generates a random session token and returns it once. It stores only a SHA-256 hash of the token. The web app on the same origin receives the token in an httpOnly cookie; the phone app and the cross-origin web deployment receive it as a bearer token. Every request for profile data then passes an ownership check before any record is read or written.

Responsibilities

  • Register and sign in accounts, with salted PBKDF2 password hashes.
  • Issue, validate, expire and revoke sessions, storing only token hashes.
  • Enforce the CSRF rule for cookie-authenticated writes and the origin check for browser writes.
  • Rate-limit authentication routes per IP.
  • Authorize each profile access as owner, editor caregiver or read-only viewer caregiver.

Inputs and outputs

Endpoint Input Output
POST /api/auth/register email, password (12–200 chars), client = web or mobile (default mobile) 201. Web: Set-Cookie plus {session: true, user}. Mobile: {token, user}. 409 if the email exists
POST /api/auth/login same 200 as above, or 401 "Incorrect email or password"
POST /api/auth/logout session Deletes this session's row and clears the cookie
GET /api/me session {id, email}
Any other /api/... Authorization: Bearer … or babycare_session cookie 401 if missing, unknown or expired

Emails are trimmed and lower-cased before lookup.

Mechanism

sequenceDiagram
  autonumber
  actor U as User
  participant C as Web or mobile client
  participant G as Guardrails middleware
  participant A as FastAPI auth routes
  participant DB as Database
  U->>C: Enter email and password
  C->>G: POST /api/auth/login
  G->>G: Rate limit auth per IP, 30 per 60 s
  G->>A: Forward request
  A->>DB: Load user by normalised email
  A->>A: PBKDF2-HMAC-SHA256, 390000 iterations, per-user salt
  A->>A: hmac.compare_digest with stored hash
  A->>A: secrets.token_urlsafe 36 bytes
  A->>DB: Insert session with SHA-256 of token, expires in 14 days
  A-->>C: Web gets httpOnly cookie, mobile gets bearer token
  C->>G: GET /api/patients/42/medicines with cookie or bearer
  G->>A: Forward request
  A->>DB: Find session by SHA-256 of presented token
  A->>A: Reject if missing or expired
  A->>DB: own_patient checks owner or caregiver grant
  alt Not owner and no grant
    A-->>C: 404 Not found
  else Viewer grant on a write
    A-->>C: 403 Read-only caregiver access
  else Allowed
    A-->>C: 200 profile-scoped data
  end
Loading

Passwords

create_password() draws a 16-byte random salt (os.urandom). hash_password() runs hashlib.pbkdf2_hmac('sha256', password, salt, 390_000). The salt and hex digest are stored in users.password_salt / users.password_hash. Login compares digests with hmac.compare_digest. The minimum length is 12 characters, enforced by the request model on sign-up, login and password reset.

Sessions

Property Value in code
Token secrets.token_urlsafe(36)
Stored sessions.token_hash = sha256(token). The raw token is never persisted, and as_json() never serialises token_hash
Lifetime SESSION_DAYS = 14. Expiry is fixed at issue time, with no sliding renewal
Validation require_user() looks up the hash and rejects a missing row ("Invalid session"), an expired row ("Session expired") or a missing user
Logout Deletes only the current session row (user._session_hash) and calls delete_cookie
Revoke all A completed password reset deletes every session of the account (Forgot Password)

Signed document URLs issued with POST /documents/{id}/signed-access are tied to the session. A test confirms they return 404 after logout.

Web cookie compared with mobile bearer

Same-origin web Mobile app and cross-origin web
Request client: 'web' client: 'mobile' (default)
Transport Cookie babycare_session: HttpOnly, SameSite=Strict, Secure when BABYCARE_ENV=production, Max-Age 14 days, Path=/ Authorization: Bearer <token>
Client storage None readable by JS (only a "signed in" flag in sessionStorage) Mobile: expo-secure-store. Cross-origin web: sessionStorage
Token in response body No Yes, once

The cross-origin web deployment (static site on https://<project>.web.app, API on https://<api>.onrender.com) uses bearer mode because a strict same-site cookie would not be sent cross-site.

CSRF and origin rules

  • Cookie writes: require_user() rejects any cookie-authenticated request whose method is not GET, HEAD or OPTIONS unless it carries X-Requested-With: BabyCare (403 "CSRF check failed"). Browsers do not let a cross-site form set custom headers, and SameSite=Strict already withholds the cookie on cross-site requests. Bearer requests are exempt because a browser never attaches a bearer header automatically.
  • Origin check: for any /api/ write that has an Origin header, secure_headers returns 403 unless the origin is the API's own base URL or is listed in BABYCARE_CORS_ORIGINS. CORS is enabled only for those explicit origins.

Rate limiting

guardrails.rate_allowed() keeps a fixed window per key, where the key is a SHA-256 of auth:<ip> or api:<ip>:

Scope Limit
/api/auth/* (login, register, logout, password reset) 30 requests per 60 s per IP
Other /api/* (except /api/health) 600 requests per 60 s per IP
Password reset request, per email 3 per hour (in password_reset.py)

When a limit is exceeded the API returns 429 with Retry-After: 60. The window is stored in the database table rate_windows by default. BABYCARE_RATE_LIMIT_STORE=memory switches to an in-process store for a single instance.

Read-only caregiver role

require_user() sets user._write_access to true for any non-GET/HEAD/OPTIONS request, except POSTs ending in /ask (record questions) or /signed-access (opening an original), which only read data. own_patient() then applies:

  1. Profile does not exist → 404.
  2. Caller is the owner → allowed.
  3. Caller has a caregiver_access row → allowed for reads. A write with role viewer returns 403 "Read-only caregiver access". Role editor may write.
  4. Otherwise → 404 (see Family Profile Isolation).

Sharing and account management (invitations, revoking caregivers) also call guardian(), which requires the caller to be the owner.

Security boundaries

  • Raw session and reset tokens never touch the database or logs. Lookups use the hash only.
  • Every response sets Cache-Control: no-store, X-Frame-Options: DENY, Referrer-Policy: no-referrer, a strict CSP and, in production, HSTS.
  • Known limitations: login skips hashing when the email is unknown, so response timing differs from a wrong password. Register returns 409 for an existing email. Rate limiting reduces, but does not remove, the enumeration risk from both. Expired session rows are rejected but not purged by these routes.

Implementation

api/security.py (hash_password, create_password, issue_session, require_user, own_patient, own_resource), api/main.py (auth routes, Credentials), api/guardrails.py (secure_headers, rate_allowed), api/config.py (PRODUCTION), api/workflows.py (guardian), web/app.js (request), mobile/App.tsx. Tests: tests/test_api.py, tests/test_workflows.py (test_web_cookie_csrf_and_session_not_exposed, test_signed_file_expiry_and_session_revocation, test_read_only_invitation_and_immediate_revocation).

Related pages

Backend Architecture · Web Application Architecture · Android Application Architecture · Family Profile Isolation · Forgot Password and Email Architecture · Security and Privacy Model

Clone this wiki locally