---
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.
