ADR-0062 — Cloudflare Access edge authentication + client service tokens (v1.3.9)¶
Status: Accepted Date: 2026-07-07 Supersedes/relates: ADR-0052 (app Bearer token), ADR-0029, ADR-0033 (remote MCP auth)
Context¶
A defensive audit of the production deployment (synapse.<domain>, TrueNAS SCALE +
Cloudflare Tunnel) on 2026-07-07 found the entire backend API public in the clear:
GET /pages, /graph, /search, /vault/meta returned the whole vault with no
authentication. The vault contained professional/confidential material, so this is a
confidentiality incident, not a backlog item. The existing app-level Bearer token
(ADR-0052, SYNAPSE_AUTH_TOKEN) was not enabled on this deployment.
We needed to close the exposure immediately, with the smallest reliable change for a solo developer, while leaving room for a real per-user authorization model later.
Decision¶
Put Cloudflare Access (Zero Trust) in front of the whole app at the edge, terminating authentication before traffic reaches Synapse. No app-level login UI is built.
- Browser / PWA: interactive One-time-PIN (or IdP) login; the
CF_Authorizationcookie carries same-origin requests through. No app change required. - Non-browser clients (native iOS app, Chrome clipper,
curl/scripts, and — where the transport allows headers — remote MCP): authenticate with a Cloudflare Access service token, sent asCF-Access-Client-Id/CF-Access-Client-Secretheaders, matched by an Access policy with action "Service Auth". Recommendation: one token per client for independent revocation. - The web frontend, the iOS app, and the Chrome clipper each gained a settings/options
field to store the service-token pair and inject the two headers at their single request
choke point (frontend:
api/base.ts::cfAccessHeaders()merged intoapiFetch). /mcp/serverandPOST /clipretain their own independent tokens; where a client cannot send CF headers (e.g. the claude.ai remote-MCP connector, Bearer-only), the path may be excluded from CF Access (Access "Bypass" policy) without downgrading protection — see DEPLOY.md §5.6b.
Edge auth is treated as interim: it answers "are you allowed in" but not "who are you, and which vaults are yours."
Alternatives considered¶
- Enable app-level Bearer auth (ADR-0052) only. Rejected as the primary control: a single shared secret, no per-identity story, and every client would need to hold it; CF Access gives real identities (email/IdP) and a managed login with no app code.
- Build app-level login now (FastAPI-Users / OIDC). Rejected for a solo evening project: password/session/MFA/reset code is security-critical surface to own. Deferred to the tenancy work below.
- Tailscale-only (no public exposure). Viable and even safer, but loses public/mobile access the owner wants; kept as the fallback posture.
Consequences¶
- Closes audit findings C1/C2 (public API, unauthenticated mutations) at the edge.
- CORS (
allow_credentials: true, permissive origin — audit I1) stays as-is: harmless while there is no app-level session/cookie to steal, to be tightened when app auth lands. - The PWA manifest must be fetched with credentials (
crossorigin="use-credentials") or CF Access redirects it to login (fixed in v1.3.9). - Long-running requests now traverse the Cloudflare edge (~100 s proxy limit); slow operations must stay under it or move to async fire-and-poll.
- Future work (deferred, warrants its own ADR): a real
user → owns → vaulttenancy model. CF Access already forwards a verified identity in theCf-Access-Jwt-Assertionheader; the backend can validate it, map to auserrow, addowner_idto vaults, and scope queries — turning edge "authentication" into app "authorization" without building a login UI.