ADR-0249: Secure external API exposure via Cloudflare Access service tokens — risk-tiered
This ADR establishes how a service’s API may be reached externally (no VPN) without weakening the security posture: a Cloudflare Access service-token (non_identity) policy is attached alongside the existing Require EntraID policy on the same app, so machine/API clients that cannot complete interactive SSO authenticate with CF-Access-Client-Id/CF-Access-Client-Secret headers. It is risk-tiered — permitted for low-sensitivity convenience APIs, forbidden for infra/secret-bearing APIs — and piloted on SABnzbd, Sonarr, and Radarr. This is the machine-auth leg of BeyondCorp/ZTNA for pitlab.
| Status | Accepted |
| Date | 2026-07-25 |
| Epic | ADO #1971 (pilot: SAB/Sonarr/Radarr); #1972 (estate-wide remediation, backlogged) |
| Amends | ADR-0150 (external-exposure hard rule) — adds posture (e) |
Context
Section titled “Context”Every externally published service in the estate sits behind a Cloudflare Access Require EntraID gate (ADR-0150). That gate is interactive: a browser completes an EntraID login. Mobile API clients cannot — a SABnzbd app (Sable) or an *arr app (Ruddarr) authenticates with an API key and has no way to complete the browser SSO handshake, so it is bounced to the Access login page. The previous options were both poor: keep the API VPN-only (works, but no off-network access), or un-gate the hostname (posture d — a public API behind only its own key, which the hard rule forbids without approval).
Cloudflare Access supports a third path: a service token — a machine credential (Client-Id + Client-Secret) that a client presents as HTTP headers, matched by a non_identity Access policy. It is the canonical ZTNA machine-auth pattern. Both the SAB client (Sable) and the *arr client (Ruddarr) support custom headers explicitly “to access instances protected by Zero Trust services”.
The realisation that this is available reframes the estate goal: secure external reachability without VPN is always desirable — but only if it stays genuinely gated and the exposure is sensitivity-appropriate.
Decision
Section titled “Decision”Adopt posture (e) — Access + service token (machine) as a sanctioned external-exposure posture, governed by a two-part test:
- Dual policy on one app. The app keeps
Require EntraID(browsers) and gains anon_identityservice-token policy (machines). Same hostname serves both; a browser without a token falls to EntraID, a client with token headers passes. - Defence in depth, not replacement. The Access service-token gate sits in front of the app’s own API key — both are required. The service token never replaces the app key.
- Risk-tiered — capability is necessary but not sufficient. An API is exposed via (e) only when both hold: (a) capability — a client that can send Access headers exists; (b) sensitivity — the API is low-blast-radius (media/*arr/download automation). Infra/secret-bearing APIs (Proxmox, PBS, Infisical — anything that mutates the estate or reads secrets) stay VPN-only even though they could be gated, because a service token is a static bearer secret on a device, assurance below interactive EntraID (no MFA, no per-user revoke, not easily rotated on manual clients — cf. ADR-0124).
- Per-service tokens. One token per service (isolated blast radius), never a shared token. Secrets live in Infisical; the policies + app→policy references are config-as-code in
tf-cloudflare(policies.tflocals +apps.tfapp policy lists). - Register + standard. Every posture-(e) host is recorded on the External Exposure Register; the External Exposure Standard carries posture (e) and the risk-tier test. Because the service token is an Access credential, the host remains Access-gated and passes the conformance gate as (a)/(b)/(c) do.
Pilot (proven 2026-07-25)
Section titled “Pilot (proven 2026-07-25)”SAB (downloads.pitbun.com), Sonarr (tv.pitbun.com), Radarr (films.pitbun.com), each with its own service token + non_identity policy. Proven end-to-end through the Cloudflare edge (PD12): no token → 302 to the Access login; with token headers + app API key → 200 and the real API body (SAB {"version":"5.0.4"}, Sonarr/Radarr system/status).
Alternatives considered
Section titled “Alternatives considered”- VPN-only (status quo). Secure, but no off-network access — the problem being solved. Retained deliberately for the sensitive tier.
- Un-gate the API (posture d). Public API behind only its own key. Rejected — violates the hard rule; a leaked key is then internet-reachable with no edge gate.
- One shared service token across the media apps. Simpler, fewer secrets to enter. Rejected — a leak forces re-issue across all apps and widens blast radius; per-service isolation is cheap here.
- Codify the tokens themselves as Terraform resources. Deferred — a service-token secret cannot round-trip through an import, and the estate’s Access policies are already managed as imported literals (a standing tech-debt). Tokens are stored in Infisical; codifying tokens+policies as full TF resources (now unblocked by the account-scoped CF token) is tracked for the remediation Epic.
Consequences
Section titled “Consequences”- New sanctioned posture (e) — the estate can now expose a low-sensitivity API externally without VPN and without un-gating. First realised for the three media apps.
- A static bearer credential per exposed API — accepted for the media tier, the same class of trade-off as ADR-0124 (static keys on manual clients). Mitigated by per-service isolation and central revocability (delete the token in CF → the client is dead).
- Estate-wide remediation is now scoped (Epic #1972, backlogged): first action is an audit classifying every service API as expose-via-(e) vs keep-VPN-only, then roll out per-service.
- CF Access policy codification is now possible — the account-scoped
claudetoken gainedAccess: Service Tokens+Access: Apps and Policiesedit, which also unblocks the long-standing “policies can’t be managed in TF” tech-debt noted intf-cloudflare/policies.tf. - Client-side gotcha to document: the header name fields must not include a trailing colon (
CF-Access-Client-Id, notCF-Access-Client-Id:) — a colon makes Cloudflare miss the header and the client is bounced to the login.