Skip to content

Security

novamem is a tiered memory service handling user- and project-scoped data. This document covers the security model, hardening checklist for production deployments, and how to report vulnerabilities.

Reporting a vulnerability

Email the maintainers privately rather than opening a public issue. We aim to acknowledge within 48 hours and ship a fix within 14 days for high-severity findings.

Security model

Identity and authentication

  • Better Auth sessions — the dashboard control plane (/api/auth/*, /v1/me/*, /v1/admin/*). Email + password sign-in via POST /api/auth/sign-in/email. Sessions stored server-side in the "session" table; revocable by deleting the row. Browser holds the session as an HttpOnly + SameSite=Lax cookie; non-browser callers may also send the same value as Authorization: Bearer <session>. Better Auth handles password hashing (scrypt by default), session rotation, and CSRF via trusted-origin checks against NOVAMEM_BASE_URL. JWT issuance on demand at /api/auth/token (15-min TTL); JWKS at /api/auth/jwks.
  • nm_… user bearers — bearer tokens for non-browser callers (MCP, CLI, scripts). Each bearer belongs to one user — typically representing a device or agent. The server stores only sha256 hashes in user_tokens; the plaintext is shown once at create time and is unrecoverable. A bearer carries every right the owning user has — it is not scoped to a project.

Authorization

  • User is the global isolation unit. Every memory entry has a user_id; user-wide entries (project_id IS NULL) are scoped by user_id on every query.
  • Project is the sub-brain isolation unit for shared memory. When an entry has project_id, project IS the access boundary — the SQL filters on project_id alone, because cross-user project members must be able to read/write shared data.
  • A user bearer or session gives access to everything its owning user can reach — the user's whole memory namespace plus every project the user is a member of.
  • Admins manage other users + system metrics. Admin auth = role admin on the Better Auth "user" row; checked on every /v1/admin/* call.

Data isolation invariants (do not break)

  1. getEntry(userId, id, {projectId}) returns the row only if (a) the row is project-scoped and row.project_id === projectId, or (b) the row is user-wide and row.user_id === userId. There is no third path.
  2. Cold collections are named novamem_<userId>_<namespace> for user-wide entries and novamem_p_<projectId>_<namespace> for project entries. User ids cannot start with p_ or be exactly p — the create-user Zod schema enforces this. Never relax that regex without also changing the cold-store collection naming scheme.
  3. Removing a project member must also revoke their project-related access. WarmStore.removeProjectMember deletes the membership row in a single transaction.
  4. /v1/me/{search,recent,neighbors,remember,forget,memories/:id} enforce project membership via checkProjectAccess. /v1/me/forget additionally re-fetches the entry's real scope and re-checks membership before deleting (defence in depth against project: null laundering).
  5. The Better Auth passthrough refuses operations that would leave zero admins: pre-handler intercepts /api/auth/admin/remove-user and /api/auth/admin/set-role (when the new role is non-admin) and returns 400 LAST_ADMIN_PROTECTED if the target is the only admin.
  6. Audit-log every admin action. Read it at GET /v1/admin/audit-log.

Production hardening checklist

If you're running novamem with real data:

Required

  • [ ] Set NOVAMEM_BOOTSTRAP_ADMIN_EMAIL + NOVAMEM_BOOTSTRAP_ADMIN_PASSWORD once on first deploy. The password is auto-scrubbed from process.env after seeding so it doesn't surface via docker inspect. Sign in with the seeded admin and change the password from the dashboard immediately.
  • [ ] Set NOVAMEM_BASE_URL to the public URL the dashboard is reachable at (e.g. https://memory.example.com). Better Auth's trusted-origin / CSRF check requires this match the browser's Origin header exactly.
  • [ ] Set NOVAMEM_COOKIE_SECRET to a strong random value (openssl rand -hex 32). The dev fallback string MUST NOT be used in production.
  • [ ] Set NOVAMEM_AUTH_MODE=user (the default).
  • [ ] Unset NOVAMEM_INSECURE_COOKIES (or set to 0). Cookies are then Secure and require HTTPS.
  • [ ] Restrict CORS via NOVAMEM_CORS_ORIGINS (comma-separated allowlist; default is same-origin only). * re-enables permissive behaviour — don't use it in production.
  • [ ] Run the server behind a TLS-terminating reverse proxy or LoadBalancer with a real cert. Authorization: Bearer … and the session cookie over plain HTTP are plaintext.
  • [ ] Do not expose Postgres / Qdrant / FalkorDB on host ports in production. The bundled docker-compose.yaml does this for local dev convenience; remove the ports: blocks for those services in production compose files.
  • [ ] Change the Postgres password (the bundled docker-compose.yaml uses novamem as the default — fine for dev, dangerous in prod).
  • [ ] Configure log shipping. Pino emits JSON to stdout; Authorization, password, and created token values are redacted via redact: config.
  • [ ] Set NOVAMEM_PG_POOL_MAX to fit your Postgres max_connections. Default 20.
  • [ ] Run a Prometheus sidecar that scrapes /v1/admin/metrics/prom. The dashboard's polling-based metrics are operational; the in-process counters reset on restart, but the 24h history chart pulls from metrics_samples in Postgres which IS persisted.
  • [ ] Configure Dependabot / Renovate. The lockfile is committed; major-version bumps need integration testing.
  • [ ] Enable container scanning (Trivy / Grype) in your image pipeline.

Operational gotchas

  • Schema is forward-only. All DDL is ALTER ... ADD COLUMN IF NOT EXISTS plus explicit DROP CONSTRAINT IF EXISTS for retired FKs. There is no rollback story beyond restoring from backup.
  • Sessions are server-side: signing out via /api/auth/sign-out deletes the row. Closing a tab without signing out leaves the session valid until its TTL (7 days by default).
  • /v1/admin/metrics resets on restart by design — in-process counters. The 24h throughput history chart pulls from metrics_samples in Postgres and survives reboots.
  • Removed project members keep nothing. Their membership row is deleted; their tokens stay valid against their own user namespace but they no longer see the project's memory.

Threat model summary

AdversaryWhat they can / cannot do
Anonymous internet userHit /health, /api-docs, /openapi.json, /admin HTML shell. Cannot read or write any memory.
Holder of a nm_… bearerRead/write everything the owning user can reach — user-global entries plus every project that user is a member of. Cannot mint other bearers, cannot reach /v1/admin/* (unless the owning user is admin). Can rotate the bearer via /v1/auth/rotate-token.
Logged-in user (role user)Manage their own bearers + projects, view their own metrics. Can share a project with another user (adds them as a member). Cannot reach /v1/admin/* or /api/auth/admin/*.
Logged-in user (role admin)Manage all users via Better Auth's admin endpoints, view system metrics, run decay / dream-cycle. Admins do not automatically inherit other users' memory access — they manage identity, not data. The last admin cannot be removed or demoted.
Compromised databaseSees Better Auth's password hashes (scrypt) in account.password, token hashes (sha256) in user_tokens. Plaintext bearers and passwords are not stored. Re-issued tokens can be invalidated by deleting the relevant rows.