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:
- 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. (
currenciesandfx_ratesare shared reference data, read-only to the app.) - Foreign keys include
book_id, so nothing can point across books. - Least-privilege logins (see Database roles): never superuser, never
BYPASSRLS, no schema changes. The API never gets the owner’s password:app/launch.pygives it onlyapi_login. Columns the database fills in (who created, edited or invited something,is_admin) can’t be written by the app. - 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. - 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. - Append-only audit log and edit history.
- Random UUIDs for every id the API exposes, so nothing can be enumerated.
- No sensitive identifiers: only the last 4 digits of a card or account number, and no bank credentials, ever.
- 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_membersorentriestable 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
Originis refused (origin_not_allowed). Phones send noOrigin. - 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, andCache-Control: no-storeon 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.1until 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 forgeX-Forwarded-Forand 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_tempif 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).