Skip to content

Security Model

Nils Lehnen edited this page Jul 12, 2026 · 16 revisions

Security Model

This plugin is the login path for your Jellyfin server, so it is built to fail closed: anything the plugin cannot positively verify is rejected, never waved through. This page describes the protections implemented today; it grows as the hardening program continues.

Identity binding (anti account-takeover)

An SSO login is bound to the identity provider's stable identifier — OpenID sub / SAML NameID — recorded as a canonical link. A first login whose name merely matches a pre-existing, unlinked Jellyfin account is refused (HTTP 403), not silently adopted, unless an administrator explicitly enables AllowExistingAccountLink for that provider. This prevents a user whose provider-supplied name equals an existing account (e.g. admin) from taking that account over.

SAML response validation (fail-closed)

A SAML response is accepted only when all of the following hold; any failure rejects the login:

  • Signature — exactly one signature, whose reference covers the element that is actually consumed (root Response or the single Assertion). This blocks XML-signature-wrapping ("verify one node, read another").
  • XML parsing — the response is parsed with DTD processing prohibited, so any <!DOCTYPE> is rejected before any other check runs. This closes both external-entity injection (XXE) and internal-entity expansion ("billion laughs") in the untrusted response.
  • Time boundsNotOnOrAfter (and NotBefore when present) are enforced with a bounded clock skew. A missing or unparseable bound is rejected (it is not treated as "valid forever").
  • Audience — the assertion's AudienceRestriction must name this service provider (configurable; opt-out available for providers that cannot emit it). This stops an assertion minted for a different service from being replayed here.
  • One-time use — a consumed assertion ID cannot mint a second session within its validity window (per-provider replay cache).

Server-side request forgery (avatar fetch)

When avatar sync is configured, the URL is derived from identity-provider claims, so the fetch is constrained to public http(s) targets: the resolved address is validated at connect time (each redirect hop too), blocking loopback/private/link-local/CGNAT/ metadata ranges and DNS rebinding, and the download is bounded in time and size.

OpenID authorize state

The in-flight authorize-state store is safe under concurrent logins, so parallel sign-ins cannot corrupt it or throw. A state is also provider-bound and single-use: it is minted for one provider and rejected outright if presented to a different provider's callback (stops a state validated at a low-trust provider from being replayed against a higher-trust one), and it lives for about 15 minutes — long enough for a provider-side MFA or consent step, short enough to bound the replay window.

Response hardening

The rendered auth page (the one that carries the one-time state token or signed assertion and completes the login client-side) sets X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, and Cache-Control: no-store — clickjacking protection, no MIME sniffing, no token leakage via the Referer header, and no caching of a token-bearing response.

Audit trail

Security-relevant events — successful logins, opt-in adoption of an existing unlinked account, and admin provider add/remove — are written as structured [SSO Audit] log entries (username, provider, admin flag; never a secret or config value).

Reporting

Report vulnerabilities privately via Report a vulnerability. Security-relevant behavior is covered by the automated test suite, and login-path changes go through an adversarial security review before merge.

Clone this wiki locally