Security

What the app enforces, what you need to configure before other people can reach an install, and what isn't handled yet.

View Markdown
Browse documentation
On this page

This page is for whoever runs an install. It covers what the app enforces on its own and what you need to set before other people can reach it. To report a vulnerability, follow SECURITY.md in the repo.

What the app enforces

Identity and company scope. Every route works out who you are through the app's auth layer and limits records to the current company. The current company is stored in an HTTP-only, same-site cookie, but that cookie doesn't grant anything: membership is checked on every request. If the cookie names a company you can't open, you're sent to the first one you can, and the client is told so it can fix the cookie.

Who gets in. Access applies to the whole install, and AUTH_SIGN_UP_CODE controls it. Anyone who registers with that code can open every company on the server, now and in the future. There's no invitation, email, or link that gives narrower access. Letting someone in means giving them every company's financial data, so use separate installs if that's not what you want.

Audit log. Each meaningful change records who made it, how it came in (ui, agent, or worker), the action, the record affected, a summary, and the before and after values. If the change runs in a transaction, the log entry is written in the same one, so both are saved or neither is. GET /api/activity returns the current company's entries, newest first, only to that company's members. Records also store their own author, so decisions, resolved review items, economic items, closed periods, and knowledge documents show who acted even without the log.

Ids in requests. Any record id sent in a request body has to belong to the current company before it's used. Follow-up updates filter on company id too, not just record id. Client-supplied company identifiers are not authorization; protected financial mutations resolve active membership and validate any company-selection header.

Row level security. A migration adds row level security policies to every company table, tied to a company scope set per transaction by the company transaction wrapper. When no scope is set, the policies allow access, so routes can move to the wrapper gradually. The policies only apply when the database role isn't a superuser.

Auth modes. AUTH_MODE=disabled has to be set on purpose; the app never falls back to it. In production it also needs ALLOW_AUTH_DISABLED=true, and the install should sit behind a VPN, reverse proxy, or similar access control. Better Auth uses an explicit base URL and an allowlist of trusted origins, and public registration is off by default in production.

How the sign-up code is checked. When AUTH_SIGN_UP_CODE is set, a server-side hook on registration compares it in constant time and returns 403 if it doesn't match, whatever the sign-in page does. This is also what stops someone else from registering the LOCAL_OWNER_EMAIL address before you. In production, the app logs a warning at startup if sign-up is on without a code.

Requests and headers. API writes reject cross-site browser requests. Responses set no-store, anti-framing, no-sniff, referrer, and permissions headers, plus HSTS in production.

Files. Reading, writing, and deleting source files all validate the storage path. Downloads are limited to the current company, checked against their hash, and always sent as attachments. Upload size and extracted text are capped, and extraction prompts treat uploaded text as untrusted.

Posting. Before writing a financial posting, the app checks company ownership, that accounts are active, period status, provenance, approvals, that the entry balances, entity identity, and the FX source and rate.

Cron and health endpoints. Scheduled jobs require the configured bearer secret, compared in constant time. Health checks require their configured secret; production returns 404 if that secret is absent. A local health endpoint may be unauthenticated when no health secret is configured.

Required production configuration

  • Set a unique BETTER_AUTH_SECRET of at least 32 random bytes.
  • Set BETTER_AUTH_URL to your canonical HTTPS origin. Add other origins only through BETTER_AUTH_TRUSTED_ORIGINS.
  • Remove AUTH_ALLOW_SIGN_UP once everyone who needs an account has one.
  • Set AUTH_SIGN_UP_CODE any time sign-up is on and other people can reach the server, and only give it to people who should be able to register.
  • Set CRON_SECRET before turning on scheduled reconciliation or calculations.
  • Set HEALTHCHECK_SECRET. Without it, production health checks return 404.
  • Connect to PostgreSQL as a dedicated non-superuser role without BYPASSRLS. Superusers skip row level security, so the policies do nothing for the default postgres role.
  • Keep local file storage out of the web root, with tight permissions and backups suited to financial records.
  • Keep secrets in a secret manager or an untracked env file, and rotate them if you think they've leaked.

Not handled yet

Both the app's rate limiter and Better Auth's default limiter live in a single process. Before running more than one instance, switch to a shared Redis or database-backed limiter, set up trusted proxy IP handling, and schedule outbox recovery with CRON_SECRET.

Per-company membership, invitations, and role management aren't in this repo. The role field, the server-side role check, the audit log, and per-record authors are what they would be built on.

Financial data and model providers

The configured provider can receive source content needed for interpretation. Self-hosting PostgreSQL and uploaded files does not make that processing local. Review your provider arrangement and the documents you choose to process.

Saved outlooks, review downloads and sign-off records contain financial inputs and source references. Treat them as financial records when sharing or backing them up. Public documentation and its Markdown downloads contain no company data.

Report sign-off preserves accountability; it is not a security boundary or an independent audit opinion. See Reviews and sign-off.