Balance handles financial data, so security is treated as a first-class concern. This document describes the threat model, the auth flow, recent fixes, and how to report vulnerabilities.
- Threat model
- Auth flow
- Recent security fixes
- Known limitations
- Supported versions
- Reporting a vulnerability
- Cross-user data isolation. Postgres RLS denies by default. Every read and write is scoped to
auth.uid(). Functions and views usesecurity_invoker = true. Database functions called via RPC re-validateuser_id = auth.uid()before mutating shared tables (see recent fixes). - API key compromise blast radius. Keys are SHA-256 hashed at rest. Plaintext is shown exactly once, on creation. The Edge Function that exchanges them for JWTs is rate-limited (5 failed attempts per IP per 5 minutes) and individual keys can be revoked instantly via
bal key revoke. - Cron endpoint abuse.
daily-chargesanddaily-backupuseservice_roleand dump per-user data. They require aCRON_SECRETbearer token and are fail-closed: if the secret is unset, every request returns 503. Backups are scoped per user, not global. - Replay / privilege escalation. The CLI never holds
service_role. It receives a short-lived JWT minted from a magic-link OTP, and refreshes via the standard Supabase refresh token flow.
- Your local machine.
~/.balance/session.json(mode 0600) andBAL_API_KEYenv vars assume an uncompromised laptop. If your machine is owned, your Balance account is owned. - Supabase platform. Balance trusts Supabase's encryption-at-rest, DB backups, network isolation, and Auth implementation. If you do not trust Supabase, do not deploy Balance there.
- Supply chain. npm dependencies are not pinned with integrity checks beyond
package-lock.json. Audit before deploying. - Phishing of your Supabase Auth credentials. Email/password recovery flows go through Supabase Auth.
- Information leakage from logs. Edge Functions log truncated user tags (
u_xxxxxxxx), but custom hosting (Logflare, etc.) might persist more. Review your log retention.
┌────────┐ bal login --api-key bal_...
│ CLI │ ───────────────────────────┐
└────────┘ │
▼
┌─────────────────────┐
│ Edge: auth-apikey │ rate-limit (5/5min/IP)
│ - SHA-256 lookup │ fail-closed
│ - mark last_used │
│ - magic-link OTP │
│ - exchange OTP │
└──────────┬──────────┘
│ access_token + refresh_token
▼
┌─────────────────────┐
│ ~/.balance/ │ mode 0600
│ session.json │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Supabase REST/RPC │ RLS enforced
│ (user JWT) │ user_id = auth.uid()
└─────────────────────┘
Key properties:
- API keys are 100% bearer-style; lose one and rotate it. There is no MAC / signature.
- The Edge Function is the only path that uses
service_role. The CLI and the web app never touch it. - JWTs default to a short lifetime (configurable in Supabase dashboard). The CLI auto-refreshes on every command.
April 2026 audit — applied in migration 20260422235333_security_patch_function_user_validation.sql.
| Area | Issue | Fix |
|---|---|---|
| SQL functions | 12 PL/pgSQL functions accepted a p_user_id parameter without verifying it matched auth.uid(), so a caller with a valid JWT could in principle write transactions, debts, or snapshots into another user's account. |
All affected functions now assert p_user_id = auth.uid() (or read it from auth.uid() directly) before any mutation. Coverage in supabase/tests/security_user_validation_test.sql (pgTAP). |
auth-apikey Edge Function |
No rate limiting; brute-forcing API keys was bandwidth-bound. | Per-IP token-bucket: 5 failed attempts per 5 minutes, 429 on overflow. Successful auth resets the counter. |
daily-charges cron |
Function ran globally and could leak charges across users in the result log. | Scoped to one user at a time, with hashed user tags in logs (u_xxxxxxxx). |
daily-backup cron |
Endpoint returned a normal response if CRON_SECRET was unset, effectively making the per-user backup dump publicly callable. |
Fail-closed: missing CRON_SECRET returns 503. Wrong secret returns 401. |
supabase/tests/security_user_validation_test.sql— pgTAP suite covering all 12 patched functions, asserting that a JWT for user A cannot mutate user B's data even with a forgedp_user_id.tests/edge-functions.test.ts— integration tests for the Edge Functions (rate limit, fail-closed paths, OTP exchange happy path).
Run with:
supabase test db
npm test --workspace tests- No 2FA. Supabase Auth supports MFA via TOTP; Balance does not yet wire it into the web onboarding flow.
- No client-side encryption. Sensitive fields (account names, transaction notes) are stored as plaintext in Postgres. Encryption-at-rest is whatever Supabase provides by default — there is no per-user envelope encryption.
- No audit-log export. The
audit_logtable records every mutation, but there is no first-class UI / CLI to review or export it. - No IP allowlist on Edge Functions. Anyone on the internet can hit
auth-apikey, and the only protection is the rate limiter + key hash. - No SCA / SBOM. Dependency scanning is up to the operator.
npm auditand Dependabot are recommended. - Single-region. Whatever Supabase region you pick is your only copy of the data outside the daily backup dumps.
These are tracked as future work, not blockers for personal use.
Balance is in beta. Only the latest commit on main is supported. There are no LTS branches. If you self-host a fork, you are responsible for your own security patching.
| Version | Supported |
|---|---|
main (latest) |
Yes |
| Older commits | No |
If you find a security issue, please do not open a public GitHub issue. Instead:
- Use GitHub Security Advisory for private, coordinated disclosure.
I aim to respond within 72 hours and patch within 14 days for critical issues. Coordinated disclosure is welcome — credit will be given in the changelog and the commit message unless you prefer to stay anonymous.
If the issue affects Supabase itself, please also report it to Supabase security.