What's implemented today and what's still on the roadmap. The implemented bits are verifiable in backend/app/main.py, backend/app/auth.py, and backend/app/config.py.
- Password storage —
passlib[bcrypt](bcryptwork factor at the passlib default of 12). Plaintext passwords never touch the DB or the logs. - Login —
POST /api/v1/auth/loginaccepts{email, password}, returns{access_token, token_type, user}. Email is the canonical identifier and is uniquely indexed onusers. - Access token — JWT signed with HS256,
exp=now + access_token_expire_minutes(default 30). Claim payload is minimal:sub(user id),email,exp, plannedorg_id+token_type. - Authorization — every router dependency that calls
Depends(get_current_user)validates the token, loads the user, and rejects inactive users. Routes that don't list the dep are open (currently only/health,/auth/login,/auth/register,/api/docs,/openapi.json). - Logout — client-side (drop the token). No server revocation list today; tokens are valid until
exp.
- Refresh tokens — a long-lived (e.g. 14 days) refresh token, rotated on every refresh, stored server-side in a
refresh_tokenstable keyed byjti+user_id. Revocation list lives in the same table (fliprevoked_at). - Token types — JWTs carry
"token_type": "access" | "refresh"soget_current_userrejects refresh tokens at access-protected routes. - Brute-force protection — a per-email login rate limit (5/minute) on top of the global IP rate limit.
slowapiwith a default of60/minute. Configurable viarate_limit_per_minute.- Token-keyed as of v1.1.0 —
key_funcderives from the authenticated user when a JWT is present, falling back toget_remote_addressfor anonymous routes. This avoids the multi-user-behind-NAT false positive. - The limiter state is in-process today. Multi-worker deployments need a shared backend; we plan to point slowapi at Redis once the worker queue lands.
- Explicit allow-list from
settings.allowed_origins(a JSON array in.env). allow_credentials=True,allow_methods=["*"],allow_headers=["*"].- No wildcards in defaults. Production must set this to your real origins.
Set by the security_headers_middleware in app/main.py for every response:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-XSS-Protection |
1; mode=block |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), microphone=(), camera=() |
Note: Permissions-Policy strips microphone access globally; the voice-annotation recorder explicitly requests microphone via getUserMedia, which prompts the user — the policy header doesn't block that, only third-party iframes.
A Content-Security-Policy header is not yet set. Planned: default-src 'self'; img-src 'self' data:; connect-src 'self' https://api.openai.com https://api.deepinfra.com; script-src 'self'; style-src 'self' 'unsafe-inline'.
.envis never committed. The.gitignorecovers.env,.env.*(with.env.exampleand.env.LOCAL_DO_NOT_COMMITdocumented exceptions).secret_keyis the only env var validated for strength — the Pydantic validator inapp/config.pyrejects any value< 32 charsor matching the known-weak default.- Backend service user should own
backend/.envwith0600permissions. - API keys (OpenAI, DeepInfra, Azure DI) are stored in
.envonly. Rotate any leaked key immediately and regeneratesecret_keyto invalidate stale tokens.
request_id_middlewareinjects a UUID4 intorequest.state.request_idfor every request and echoes it back asX-Request-IDon the response.- The log line emitted per request includes the id:
request_id=<uuid> method=POST path=/api/v1/projects/1/review status=201 duration_ms=14.2. - File bug reports with the
X-Request-IDvalue and we can grep the full request lifecycle out of the log.
Single-tenant today. The roadmap lives in docs/DATABASE.md → Multi-tenancy roadmap. Summary:
- Add
organization_idto every domain table. - Embed
org_idin JWT claims. - Enforce in app dependencies first, then PostgreSQL Row-Level Security policies once on Postgres.
- Append-only audit log keyed by
org_id,user_id,request_id,action,entity_type,entity_id.
TRUSTED_HOSTS (Starlette TrustedHostMiddleware) is also planned for this milestone, so the backend rejects Host headers that don't match the configured deployment domain.
- No CSRF protection. The frontend uses bearer-token auth (not cookies), so CSRF isn't directly applicable, but any future cookie-based flow needs the standard double-submit pattern.
- No file upload virus scanning.
voice_annotationsaccepts audio blobs by extension only — content sniffing is on the v1.2.0 list (planned dep:python-magic). - No PII tagging. Architects' license numbers and project addresses are stored in cleartext. Encrypt-at-rest if your jurisdiction requires it (Postgres TDE or filesystem-level).
- No SBOM / dep scanning in CI. Planned:
pip-audit+npm auditas required-status checks.
Report vulnerabilities privately — please don't open public GitHub issues for security bugs. A SECURITY.md policy file with a contact mailbox is on the v1.2.0 list.