---
key: security.fail-closed-api-auth
title: '2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)'
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning.
signals: 'fail-closed auth, sensitive-read tier, CORS allowlist, single API key · paths: `IApiKeyProvider`, `ErsatzTV/Services/ApiKeyProvider.cs`, `FileSystemLayout.ApiKeyPath` · issues: #197, #280, #281, #282, #284, #285'
mechanics: '`docs/api-conventions.md` §5; `ApiControllerSecurityTests`'
---

Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
Owner decisions (confirmed this session): **single API key** (no read/write split), and
**`Api:RequireKeyForReads` defaults `true`** (the whole `/api` surface requires the key). This does
**not** affect Jellyfin/streaming: `/iptv/*` (playlist/guide/streams/logos) and `/artwork/*` are outside
the filter's `/api` scope and keep their own optional access-token; only the management API the SPA talks
to is gated.

- **Fail-closed writes (#280, S1).** The empty-key "open" branch is deleted; there is no open mode. New
  `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`, singleton, resolved once at startup) yields a
  never-empty key: `Api:WriteKey` if set, else a key persisted at `FileSystemLayout.ApiKeyPath`
  (`/config/api.key`, `0600`, path logged not value), else a generated 256-bit hex key. Every mutating
  `/api` request now requires `X-Api-Key`.
- **Sensitive-read tier (#282, S3/S5).** Reads are gated by `Api:RequireKeyForReads` (default true) OR a
  new `[RequiresApiKey]` marker (mirror of `[SkipApiKeyAuthorization]`) applied to
  `Troubleshoot`/`Logs`/`Settings`/`Maintenance`, so that tier stays gated even if an operator opts reads
  open. `OPTIONS` preflight is exempt (CORS middleware owns it). `ApiControllerSecurityTests` asserts the
  tier reflectively.
- **Delete dead non-`/api` mutation surfaces (#281, S2).** `SortController`
  (`POST media/collections/{id}/items`, dead Blazor SortableJS residue — the SPA uses
  `PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC)
  bypassed the key because they sat outside `/api`. Removed rather than guarded.
- **CORS opt-in (#284, S6).** `AllowAnyOrigin/Method/Header` is replaced by the `ApiCors` policy: an
  exact-origin allowlist from `Api:CorsAllowedOrigins` (semicolon list) that permits `X-Api-Key`/`If-Match`
  and exposes `ETag`; with no origins configured there is no cross-origin access (the SPA is same-origin).
- **ForwardedHeaders trust + scanner loopback (#285, S7/S10).** `GET /api/maintenance/gc` → `POST`
  (crawler-triggerable GC; spec regenerated). `ForwardedHeaders` trust is configurable via
  `ForwardedHeaders:KnownProxies`/`KnownNetworks` — **unconfigured preserves the current trust-all
  behavior but logs a warning** (flipping the default to loopback-only would break reverse-proxy scheme/host
  detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). `ScannerController`
  gains `[LocalhostOnly]` (the scanner always calls back over `http://localhost:{UiPort}/api/scan/...`), which
  is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. `search/all-items`
  DoS-paging is **deferred** (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth
  exposure is already closed by read-gating).
- **SPA (`web/`).** The client sends the stored key (`ctv-api-key`) on **every** method (not just
  mutations); a new keyless **API Key** screen (`/app/api-key`) lets the user paste the generated key, and a
  shell-level banner points there on any 401. See spa-conventions §5e. **First-run/upgrade UX:** with reads
  gated by default, the SPA shows no data until the key (from `/config/api.key`) is entered — an intended
  consequence of the strict default.

Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1`
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items`
paging, per-key rate limiting.
