ADR-0045: optional built-in authentication for the web UI, and the deployment contract that goes with it
- Status: Accepted (owner sign-off 2026-09-05 on PR #756, decision gate per ADR-0026 step 1)
- Date: 2026-09-05 (signed the same day)
- Answers: OQ-8 (built-in auth as a deployment assumption, open since the
2026-06-12 PRD) and D-1/D-2 of the 2026-09-05 security review triage
(
_bmad-output/planning-artifacts/security-review-triage-2026-09-05.md).
Context
NFR-4 says the web UI is unauthenticated by design: trusted network or reverse-proxy authentication; optional built-in auth is OQ-8. The PRD made OQ-8 a precondition for Phase 3, on the reasoning that live broker credentials must not sit on a box whose UI anyone on the network can open.
A whole-system security review on 2026-09-03 found that the precondition is earlier than Phase 3, for three reasons that are about the delivered system rather than the decision:
- The production configuration binds every network interface with no switch; only the development configuration has the loopback default.
- The documented home deployment is the development Compose file: debug error pages, origin checks off, the repository’s public session secret, the database published on every interface with its default password, and a fallback value for both bearer tokens.
- No request is checked against its
Hostheader. The origin check guards the WebSocket only; a server-rendered page is readable by any site the operator’s browser visits, through DNS rebinding. The trust boundary of an unauthenticated home-network service is therefore not the network but the operator’s browser, which is on the internet.
Every write the product has is reachable from the UI, so the bearer token on the API protects nothing an attacker on the same network wants. The identity gate (2026-08-12) settled that self-hosting by others is the deployment model; what an adopter following the documentation gets today is not a tool for a trusted network but a tool for no network.
Decision
1. One password, opt-in, enforced on every browser route and the socket
- A single operator password is read from
PORTFOLIXIR_UI_PASSWORDat runtime. Unset means today’s behaviour, so no existing instance changes on upgrade. - When set, a plug on the browser pipeline requires an authenticated session: an unauthenticated request is redirected to a minimal login page (password only, no username, no registration, no recovery); a correct password stores a flag in the signed session cookie; a logout control clears it.
- The browser
live_sessiongains anon_mountthat refuses to mount a socket whose session lacks the flag, so the LiveView transport cannot be used to bypass the plug. - The password is compared in constant time and never logged. Failed attempts are throttled per source address with an exponential back-off.
- The
/api/v1routes are untouched: the bearer tokens remain the agent’s credential, and the UI password is never accepted there.
Amendment, 2026-09-06 (#777): how long a login lasts. The decision above
chose a session flag but never said what a session’s lifetime is, and the
implementation inherited Plug’s default — a cookie that dies with the browser,
which asked the operator for the password several times a day. The lifetime is
now PORTFOLIXIR_SESSION_DAYS, default 30, 0 keeping the
browser-session behaviour for anyone who wants it. Two properties matter more
than the number:
- The server decides, not the browser.
Plug.Sessionkeeps:max_ageamong the cookie options and hands the store none, so a signed session cookie verifies for as long asSECRET_KEY_BASEis unchanged, whatever expiry the browser was told. The authoritative check is a timestamp in the session, verified on every browser request and at every LiveView mount; the cookie’smax_ageis browser hygiene beside it. - The window slides on use. A session is re-stamped once its stamp has aged past half the lifetime, capped at a day, so an operator who uses the instance regularly is never asked again, while an instance left alone for the full lifetime asks once. A fixed window would have asked every 30 days regardless of use, which is the same annoyance on a longer fuse.
Revocation stays what it was: a logout drops the cookie and disconnects that
session’s LiveViews, and rotating SECRET_KEY_BASE invalidates every session
everywhere. With longer-lived cookies that second lever is the one to
document, and SECURITY.md now does.
2. The deployment contract
- Production binds loopback by default.
config/runtime.exsgains thePHX_BIND_ALLswitchconfig/dev.exsalready has. Binding beyond loopback with no UI password set logs a warning at startup naming this ADR. - The request’s Host is validated by a plug ahead of the router: the
configured
PHX_HOST,localhostand127.0.0.1, extendable by an environment variable for a reverse-proxy host. Anything else is answered 421. - The session cookie carries
SameSite=Lax,HttpOnlyand, behind a proxy that setsx-forwarded-proto,Secure; the signing salts are derived fromSECRET_KEY_BASErather than the two literals in the repository. HSTS withforce_sslis available as an opt-in variable for an instance that terminates TLS itself. - The documented deployment is a production configuration: a release build, every secret required with no fallback, the database port not published, the application port published on loopback for the operator’s reverse proxy, non-root users and digest-pinned images. The current Compose file is kept as the development configuration under its own name.
3. What this is not
- Not a user model, not roles, not per-portfolio permissions; NFR-6 (one operator) is unchanged.
- Not a replacement for reverse-proxy authentication; an operator who has one keeps it. The two compose.
- Not a change to how agents authenticate; ADR-0017’s actor taxonomy and the token surfaces are unchanged.
- Not a claim of production readiness (
AGENTS.md), and not the Phase 3 gate: it answers OQ-8, which Phase 3 lists as one of three preconditions.
Consequences
- NFR-4 reads unauthenticated by default, authenticated by one variable;
loopback by default; Host-validated. README and
docs/index.mdchange their sentence accordingly. - An adopter following
docs/home-deployment.mdgets an instance that is reachable only from the machine it runs on until they choose otherwise, and a one-variable way to open it to a network safely. - The login page is a new user-visible surface: it follows
DESIGN.md, carries the DE/EN strings through gettext, and is covered by the closing act’s browser conditions. - The API/MCP coverage rule does not apply to the password itself (it is a browser credential by construction); the reviewer briefing states this.
- One new test family in the invariant suite: the Host guard, the cookie attributes, and the socket mount refusing an unauthenticated session.
The asks this ADR answers (ADR-0043)
| Ask | Answer |
|---|---|
| OQ-8 — should the app carry built-in authentication? | Answered: yes, optional, one password, opt-in by variable (§1). |
| D-2 — should the documented deployment be a production configuration? | Answered: yes (§2). |
| Should authentication cover the API too? | Answered, no: the bearer tokens stay the agent’s credential (§3). |
| Multi-user, roles, per-portfolio access? | Deferred, with reason: NFR-6 says one operator; the parking lot (#340) holds multi-user as a vision item, and nothing in this review needs it. |
| Should the UI password gate Phase 3 credential storage? | Deferred to the Phase 3 ADR: this ADR discharges the precondition; how Phase 3 uses it is that ADR’s to say. |