---
title: Security
section: Install and operate
order: 42
summary: What the app enforces, what you need to configure before other people can reach an install, and what isn't handled yet.
---

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](/docs/reviews-and-signoff/).
