--- key: security.iptv-browser-token title: 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552) status: active since: '2026-07-22' supersedes: none superseded-by: none rule: Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. signals: 'channel preview / playback-troubleshooting 401ing under JWT; `/iptv/*` not accepting `ctv-session`; minting a JWT for the browser · paths: `ErsatzTV/JwtHelper.cs`, `ErsatzTV/Controllers/Api/AuthController.cs`, `web/src/media/iptvToken.ts` · issues: #552, #60' mechanics: '`JwtHelper.GenerateBrowserToken()`; `AuthController.IptvToken`; `withIptvToken(url)` (SPA)' --- `/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` only when `JWT:IssuerSigningKey` is configured, and the `"jwt"` scheme accepts only a bearer token or `?access_token=` — **not** the SPA's `ctv-session` cookie (a distinct scheme). Nothing minted a JWT for the browser, so under JWT the #60 channel preview was declared `Unavailable` and the pre-existing playback-troubleshooting screen was latently broken. This closes both with one seam. - **Endpoint.** `GET /api/v1/auth/iptv-token` on `AuthController` (already `[SkipApiAuthorization]` + self-checks the principal, like `machine-key`). Requires any authenticated session (401 otherwise); returns `{ token, expiresAt }` when `JwtHelper.IsEnabled`, else **204 No Content** — `/iptv/*` is open then, so there is nothing to append and the SPA plays the plain URL. `Cache-Control: no-store`. Excluded from the OpenAPI document (`AuthController` is `[IgnoreApi]`, per the #295 F4 ruling — a browser-interactive credential is not something a generated client drives). - **A GET is correct here** despite the "no side-effecting GET under `/api`" rule (`api-conventions.md §9`): minting a JWT writes **no server state** (stateless token, no DB row, no revocation list), so it is not a CSRF-relevant side effect, and same-origin policy already blocks a cross-site page from reading the credentialed response body — identical reasoning to the `machine-key` GET. - **Scope: global.** The `"jwt"` scheme validates only signature + lifetime (no audience/channel claims), and the token is minted only to the already-authenticated admin who can reach every channel. Channel-scoping would mean adding claim-based auth to `ConditionalIptvAuthorizeFilter` and the streaming path — deferred until a non-admin preview audience exists. - **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h.** The token re-validates on every `/iptv/*` request, so lifetime is the max continuous watch before playback stalls; 60 min comfortably covers an operator verification session, an expired idle session just needs Retry (mints fresh), and a security-conscious operator can tighten it. A non-positive/unparseable value falls back to the default rather than minting an already-expired token; a value above 24h (a seconds-vs-minutes typo would otherwise mint a multi-year bearer token) is clamped down. **Revocation is by short lifetime only** — a stateless JWT has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all tokens (the existing lever). The SPA's `resetIptvTokenCache()` (called on the preview panel's Retry and on each troubleshooting Play) makes a user-initiated retry re-mint, so a stale token or a stale "JWT disabled" latch from a since-reconfigured backend can't wedge a recovery attempt. - **Deferred hardening (broader than #552) — RESOLVED by `security.iptv-access-token-transport` (#421, #559).** The `?access_token=` transport itself had pre-existing weaknesses this feature inherited, then bounded by the short lifetime: Serilog's request log included the full query (so a token could reach logs on an `/iptv` 5xx), and the token-bearing dynamic manifests carried no `Cache-Control: no-store`. Both predate this feature (Jellyfin and the M3U playlist already pass `access_token` in `/iptv` URLs); their fixes were cross-cutting changes to shared request-logging / manifest behavior, tracked as a follow-up and now landed in the record below (which also adds the #421 M3U quote-safety encoding). - **Only the top-level manifest needs the token.** The multi-variant playlist embeds `access_token` into its variant URL (`IptvController.GetMultiVariantPlaylist`), and HLS segments are served by `UseStaticFiles` at `RequestPath=/iptv/session` — **outside** `ConditionalIptvAuthorizeFilter` (which is a `[ServiceFilter]` on `IptvController` only) — so segment GETs are ungated regardless. The SPA's `withIptvToken(url)` appends the token to the one manifest URL (a no-op when JWT is off; caches the token in memory until shortly before expiry) and is used by both the channel-preview panel and the playback-troubleshooting screen.