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.

OpenID id_token validation

The id_token returned by the provider is validated before any identity is read from it; any failure rejects the login:

  • Signature — verified against the provider's published JWKS (from its discovery document). Only asymmetric algorithms are accepted (RS256/384/512, PS256/384/512, ES256/384/512); a token signed with a symmetric algorithm (HS256) or left unsigned (alg: none) is rejected regardless of configuration. A rotated signing key is picked up automatically.
  • Issuer — must match the discovery issuer. The per-provider DoNotValidateIssuerName escape hatch relaxes only this check; signature, audience and expiry validation have no off switch.
  • Audience — must include this client. When an azp claim is present it must equal the client id, and a multi-audience token without azp is rejected — it may have been minted for a different party that merely lists this client as a co-audience.
  • Lifetimeexp (and nbf when present) are enforced with a bounded clock skew.
  • When the provider sends an at_hash claim, it must match the issued access token.

What your provider must be configured to satisfy is described in the id_token requirements.

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.

Rate limiting (optional)

The anonymous SSO endpoints (challenge, callback, and token exchange, for both OpenID and SAML) can be rate-limited per client address to blunt brute-force and flood attempts. It is opt-in and off by default, configured in the plugin's XML configuration:

Setting Default Meaning
EnableRateLimit false Master switch.
RateLimitMaxAttempts 30 Requests allowed per window, per endpoint stage.
RateLimitWindowSeconds 60 Window length.

A throttled request gets 429 Too Many Requests with a Retry-After header. One login spends one request per stage (challenge → callback → token exchange), so the default is generous for real users while still catching sustained abuse.

Behind a reverse proxy, configure Jellyfin's own Known proxies networking setting. The limiter keys on the connection's remote address only and deliberately never parses X-Forwarded-For itself — a client-spoofable header must not be able to steer or evade throttling. With Known proxies set, Jellyfin resolves the real client into that address. Without it, every request appears to come from the proxy: if the proxy address is non-public (loopback/private — the common local-proxy case) no bucket is created and the userbase is never pooled, but a proxy on a public address would share one bucket across all clients, which is why Known proxies matters.

Non-public source addresses are never throttled; IPv6 clients are grouped by their /64. The limiter is best-effort and in-process — counters are per Jellyfin instance and reset on restart — so it blunts sustained brute force at the public edge rather than enforcing a hard quota.

Secret handling

The OpenID client secret is write-only across the API: it is persisted in the server-side configuration but withheld from every configuration response, so it never reaches the admin browser (or a HAR capture, proxy log, or shared screen) after the first save. On the settings page the secret field starts blank — leave it blank to keep the stored value, or enter a new secret to replace it. The SAML certificate is the identity provider's public signing certificate (used to verify its assertions), not a secret, so it stays readable.

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