MSaving

Security model

What protects users' financial data, and the rules every change must keep.

Security is a hard requirement (personal finance data). Most of it is enforced by the database and covered by tests. Each operator is responsible for their own server (HTTPS, updates, backups); the software makes the safe choice the default.

Enforced by the database, and tested:

  1. Row-level security on every table that holds user data. Every query is limited to the books the user is a member of, with what their role allows; with no user set, those queries return nothing. An app bug that forgets a filter still can’t reach another user’s data. (currencies and fx_rates are shared reference data, read-only to the app.)
  2. Foreign keys include book_id, so nothing can point across books.
  3. Least-privilege logins (see Database roles): never superuser, never BYPASSRLS, no schema changes. The API never gets the owner’s password: app/launch.py gives it only api_login. Columns the database fills in (who created, edited or invited something, is_admin) can’t be written by the app.
  4. Sign-in data is behind functions. Password hashes, two-factor secrets, recovery codes, sessions, invites, reset links and the instance settings are reached only through security definer functions or own-row policies. Nobody can make themselves admin; every admin tool checks am_admin() in the database.
  5. Nothing for anyone by default. Functions aren’t executable by PUBLIC, including ones later migrations add. On an operator’s own Supabase database, Supabase’s browser roles (anon, authenticated) get no access to any table or function either.
  6. Append-only audit log and edit history.
  7. Random UUIDs for every id the API exposes, so nothing can be enumerated.
  8. No sensitive identifiers: only the last 4 digits of a card or account number, and no bank credentials, ever.
  9. No look-alike tables: every function that reads tables, with extra rights or as a trigger, searches the session’s temporary tables last, so a session can’t plant its own invites, book_members or entries table to fool a check. Tested.

Sign-in (Sign-in and accounts):

  • Passwords at least 10 characters, stored as argon2id hashes. A wrong username and a wrong password get the same answer, in the same time.
  • Sessions are random tokens; the database keeps only their SHA-256, so a stolen backup can’t sign anyone in. 30 days unused, sign-out, a password change (other devices), a reset or disabling the account ends them.
  • Two-factor (TOTP) optional, with ten one-time recovery codes; turning it off needs a current code.
  • Invite-only by default, with one-use, expiring codes. The first admin needs the setup code printed in the server’s log, so whoever finds a new server first can’t take it over.
  • Rate limits on sign-in (per address and per username), sign-up, setup, resets, invite checks, code entry, inviting someone to a book by username (an invite that works shows the username exists) and changing one’s username (a taken name answers username_taken). Username lookup doesn’t have one yet.

The web app and the API:

  • Only this server’s own web app can call the API from a browser: a request with any other Origin is refused (origin_not_allowed). Phones send no Origin.
  • Strict Content-Security-Policy on the web app (scripts only from itself, no third-party scripts, fonts or analytics), Referrer-Policy: no-referrer (reset and invite links carry secrets), nosniff, and Cache-Control: no-store on every API answer.

The operator’s side (the README says each):

  • HTTPS for anything beyond the machine itself: Caddy with their domain, Tailscale, or Cloudflare Tunnel. The server listens on 127.0.0.1 until they choose. Plain HTTP on a home network is allowed and warned about.
  • Backups are owner-only files holding password hashes; copying them elsewhere is the operator’s job.
  • The one-click updater can control Docker (root-level access), so it’s off unless the operator turns it on.
  • Known limit: behind no proxy at all with BIND=0.0.0.0, a client can forge X-Forwarded-For and spread its attempts over fake addresses; the per-username limit still applies.

Payees and notes aren’t encrypted field by field. That would break search and reports, and against the realistic threats (another user, a stolen backup, an app bug) row-level security and the operator’s disk encryption cover them.

Rules for every change

  • A new table gets RLS, policies and grants in the same migration.
  • A new function is granted to the roles that call it (EXECUTE is revoked from PUBLIC), and sets search_path = pg_catalog, public, pg_temp if it reads tables.
  • Never give the API the owner’s login, and never add a setting that weakens a default silently.
  • Never log a token, code or password (the setup code is printed on purpose, only while there’s no admin).
  • Never add third-party scripts, analytics or tracking.
  • Only the last 4 digits of a card or account number; never bank credentials.
  • Never send raw transactions, notes, account names or people to an AI model (when AI ships, it reads monthly summaries only).

Edit this page · Report a problem