Authentication
Passwords are hashed with bcrypt cost 12. Login is timing-equalized against a dummy hash so unknown emails are not distinguishable by response time. Failed logins are audited with the attempted address — never the password.
Stage 11 · Security engineering
Health stories are personal. This page explains exactly how this system authenticates, authorizes, validates, limits, stores and audits — with a live status readout from this very deployment.
Querying GET /api/security/status …
Access control
Three independent layers — credentials, sessions and per-object checks — each verified on every request.
Passwords are hashed with bcrypt cost 12. Login is timing-equalized against a dummy hash so unknown emails are not distinguishable by response time. Failed logins are audited with the attempted address — never the password.
The session cookie is HttpOnly, SameSite=Lax and Secure in production, carrying an HS256 JWT. A signature is not enough: every request also resolves a row in user_sessions, so logout invalidates the token server-side. Other devices keep their own independent sessions.
Every story, document, follow-up and communication route re-checks ownership against the session user — reads and writes both. Probes of foreign ids return 403 and write an ACCESS_DENIED audit entry recording who tried what.
Untrusted input
Every JSON body passes a content-type check (415), a size cap (413), a JSON parse (400) and then a strict zod schema. Unknown keys are rejected with a field-level 400, so a payload carrying userId, currentVersion, status or storageKey never reaches the database — those values are derived server-side from the session and the request context.
Fixed windows per user+IP, tunable through environment variables. Responses carry Retry-After and each 429 writes a RATE_LIMIT_TRIGGERED audit entry.
Data at rest
Uploads must pass an extension allow-list, extension↔MIME consistency, magic-byte checks, a size cap and strict base64 decoding. Bytes are written under a git-ignored storage/ directory outside the web root; the key is generated by the server from user/story/document ids and re-checked against traversal on read.
Schema changes ship as reviewed Prisma migrations (baseline + Stage 11 migrations, see DATABASE.md). Every multi-row write — story + first version, events + audit + version, follow-up + items — runs in a single transaction, so a failure rolls back the whole unit. There are no half-written stories.
Successes and failures are both recorded: logins (success and failure), logout, denied object access, rate-limit trips, story, event, question, follow-up and document writes, AI operations and communication generation — every action in the AuditAction enum (live list in the status readout above).
Story Integrity
Foreign keys guarantee references exist; they cannot prove that a file on disk matches its row, that a version chain is unbroken, or that confirmation flags agree with status. GET /api/stories/:id/integrity proves exactly those things — read-only, per story, eight checks:
Verification
The security suite is part of the repository — run it against a fresh dev server.
# terminal 1 npm run dev # terminal 2 — ~150 assertions, exit 0 = all pass npm run test:security # database story: migrations, seed, integrity npm run db:reset && npm run db:seed
Full detail and the hardening roadmap: SECURITY.md · schema and migrations: DATABASE.md