Skip to content

Security Model

iderex edited this page Jul 20, 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.

This page covers the login path — the invariants that keep a sign-in fail-closed. Its complement is the Threat Model page, which models the AI-assisted delivery pipeline that produces the plugin (a STRIDE model of the agent, its configuration, and the CI/release pipeline). Together they cover both what the plugin does and how it is built.

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.

An administrator can revoke a user's SSO access with POST /sso/Unregister/<username> (elevated privileges required; the request body names the authentication provider the account switches back to). The user's canonical links are removed across every configured provider and the change is persisted, so an SSO login no longer resolves to the account. Under the fail-closed default the revocation is durable; if a provider has AllowExistingAccountLink enabled, a same-named login can re-adopt the account, so a hard revoke there also needs the local account disabled or renamed.

Disabling a provider takes effect immediately for every grant: a disabled provider is treated exactly like an absent one on the login resolve, the link write, the legacy-link migration, and manual link creation — including when it is disabled mid-request, between the endpoint's own check and the write. Link removal still works on a disabled provider, so disable-then-clean-up is never blocked. A request already past its last configuration transaction may still complete once; the configuration lock is deliberately not held through session minting.

Account links are bound to their issuer (repointing protection)

Each OpenID canonical link records the id_token iss it was minted under, so a link is the full (iss, sub) pair, not a bare sub. This matters when a provider entry is repointed at a different identity provider: because a sub is only unique within an issuer, a new IdP that mints a short numeric subject like 1 could otherwise collide with an accumulated link and silently sign the new user in to the old user's account. Two guards close that:

  • Login-time issuer check. Every login compares the resolved link's stored issuer to the login's issuer; a mismatch refuses the login (fail closed) — this catches a swapped IdP even behind an unchanged discovery URL (a DNS/backend repoint). Re-establish the link with the admin linking endpoint, or clear the stale links, to recover.
  • Clear-on-endpoint-change. Changing a provider's endpoint URL drops that provider's accumulated links and issuer bindings — the same way the stored client secret is dropped on an endpoint change. An edit is treated as re-identifying the provider, so users re-link on their next login (or via the admin endpoint); plan a short re-link window if you change an endpoint on purpose, and leave the endpoint string untouched for a purely cosmetic edit.

Links created before this feature carry no stored issuer; they keep working unchanged and are stamped with the current issuer on the next successful login (trust-on-first-use), so upgrading does not lock anyone out on its own. Full detail, including the one accepted residual for a provider repointed before this feature shipped, is in OpenID Connect id_token requirements below.

Legacy username-keyed links honor the same opt-in (upgrade note)

Very early OpenID links were keyed on the username, not the sub. Because a username is something the identity provider can reassign, following such a link is name-based account matching, and it is gated behind the same AllowExistingAccountLink opt-in as adopting a same-named account. With the flag off (the default), a legacy link is not followed: a login whose name still matches its account is refused (403), and one whose name was freed by a rename gets a fresh account rather than the old one — so an attacker who obtains a victim's old username cannot be handed the victim's account.

The trade is an upgrade impact: an install that predates the stable-sub keying has all its OpenID links username-keyed and AllowExistingAccountLink defaulting off, so OpenID logins are refused until an administrator migrates them. Before affected users log in again, either enable AllowExistingAccountLink for the provider as a short, supervised maintenance window (be aware that while it is on, that provider trusts the IdP-supplied name, so whoever logs in first with a given name claims that account — keep the window short and turn it back off), or link accounts individually with the admin endpoint, which needs no name trust. Keep at least one non-SSO administrator with a password login before upgrading; if every admin is SSO-provisioned they can be locked out, and the only recovery is to stop Jellyfin and set <AllowExistingAccountLink>true</AllowExistingAccountLink> in the provider block of config.xml by hand, then restart, migrate, and revert.

An administrator account is never self-migrated this way, flag on or off: a returning admin who held a pre-upgrade username-keyed link is refused on their first post-upgrade login even with AllowExistingAccountLink on — the admin-takeover ceiling above (no name-based adoption of an admin account) applies to legacy links too — so re-link it explicitly with the admin endpoint from a break-glass, non-SSO admin instead of expecting self-migration. A user whose Jellyfin account was renamed and who logs in before the flag is enabled lands on a fresh empty account and is not recoverable by the flag afterward — an admin must delete the new link and re-link the original sub. The full step-by-step is in OpenID Connect id_token requirements below.

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). The cache is enforced on the login callback and on the account-linking callback, so a captured assertion cannot be replayed to bind its identity to another account either.
  • Bounded input — a response larger than 256 KB of Base64 is rejected before any decoding or XML parsing. Real responses are single-digit kilobytes; the cap stops a multi-megabyte body from costing memory and CPU on the unauthenticated callback before a single signature check has run.

Two further per-provider checks are opt-in (both off by default, set through the SAML provider configuration):

  • ValidateRecipient — the assertion's bearer SubjectConfirmationData/@Recipient (and the Response Destination when present) must equal this server's assertion-consumer URL, so an assertion minted for a different endpoint — or a different service provider sharing the same identity provider — cannot be presented here. The expected URL is only as trustworthy as host resolution, so pin it with BaseUrlOverride.
  • ValidateInResponseTo — only solicited responses are accepted: the assertion's InResponseTo must match an AuthnRequest this server issued and has not yet consumed (one-time, time-bounded). Enabling it refuses IdP-initiated (unsolicited) SSO, which completes the browser-binding defense below for providers that can issue unsolicited responses.

Operational details and caveats for both are in the Hardening & Options Reference § SAML response binding.

Server-side request forgery (outbound fetches)

Every server-to-provider fetch is constrained to public http(s) targets by one shared hardened transport: the resolved address is validated at connect time (each redirect hop too), blocking loopback/private/link-local/CGNAT/metadata ranges and DNS rebinding. This covers both the avatar fetch and the OpenID backchannel — discovery, JWKS, the token exchange, and userinfo — so a provider endpoint (or a discovery redirect) that resolves to a private/loopback address cannot be reached from the login path either. The guard lives once in the plugin's outbound-HTTP home and is registered as the named outbound client the OpenID fetches resolve, so the two paths cannot drift apart.

The avatar fetch adds two more bounds: the download is capped in time and size, and the response must declare a raster image content type (PNG, JPEG, GIF, WebP) — vector types, notably SVG, are refused, because a stored SVG can carry script and become a stored-XSS vector if later served executably. The avatar is best-effort: a refused avatar is logged and skipped, the login itself proceeds.

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.

The store is also bounded. Past a fixed ceiling a new challenge is refused (that sign-in fails cleanly and can be retried) rather than evicting a state belonging to a login already in flight — so a flood of anonymous challenge requests cannot grow memory without bound, and cannot knock out users who are mid-login either. The expired-state sweep and the capacity warning are throttled, so the same flood cannot amplify into CPU load or log volume. Each client is additionally held to a small share of that budget — see In-flight login capacity.

Login browser binding (forced-login defense)

Every login is bound to the browser that started it. When the challenge begins, the plugin sets a short-lived Secure, HttpOnly, SameSite=Lax, __Host--prefixed cookie carrying a fresh random id — __Host-sso_oid_state_binding for OpenID, __Host-sso_saml_state_binding for SAML; two separate cookies, so the flows cannot satisfy each other's check — and records the same id on the in-flight state. The session-minting callback proceeds only when the returning browser presents the matching cookie; a missing or mismatched cookie is refused (fail closed). This ties the login to the initiating user-agent as the OAuth 2.0 Security BCP requires, closing the forced-login / session-fixation vector where an attacker starts a flow of their own and lures a victim into completing it, silently signing the victim in as the attacker. The __Host- prefix makes the browser enforce Secure/host-only/Path=/, so a sibling subdomain cannot plant a same-named cookie to poison the check (cookie tossing).

It is automatic, with no configuration. Consequences worth knowing:

  • HTTPS is required at the browser edge. The Secure cookie is only stored and returned over HTTPS (a TLS-terminating reverse proxy is fine — the browser sees HTTPS). Over plain http:// the cookie never comes back and every bound login is refused.
  • Complete the login in the same browser that started it, and serve the whole flow from one origin — a callback opened in a different browser, or after clearing cookies mid-flow, is refused; just start the login again.
  • SAML scope. The SAML binding covers solicited (SP-initiated) logins; an IdP-initiated response answers no request this server issued, so binding cannot cover it — enable ValidateInResponseTo (above) to refuse unsolicited responses and close the vector completely. A solicited response whose recorded request is gone — expired (~15 minutes), or the challenge and the response landed on different instances behind a load balancer without session affinity — is refused rather than accepted unbound, because a lost correlation must fail closed. Run a single Jellyfin instance or sticky sessions for SP-initiated SAML.

In-flight login capacity (per-client)

Each started login holds one short-lived in-flight entry (an OpenID authorize state or a SAML outstanding-request record) until it is completed or expires (~15 minutes). Both stores are globally capped — refuse-at-cap, never evict — and, so a single anonymous source cannot fill that global budget and lock out everyone's logins, each client is additionally held to a 1% share of it (up to 1,000 concurrent in-flight logins per client). This is automatic and always on; a real user starting a handful of logins never approaches it.

The share is keyed on the client IP the plugin sees, so behind a reverse proxy set Jellyfin's Known proxies — otherwise every request is attributed to the proxy's address. Private/loopback sources are exempt from the per-client share (they are un-attributable — typically the whole userbase behind one hop) but remain bounded by the global cap. Behind a public-addressed proxy with Known proxies unset the whole userbase is attributed to that one address and shares a single 1,000-in-flight bucket — enough for normal use, but a very large deployment could brush it, and setting Known proxies resolves it (and is correct regardless). A refused login returns a generic "could not start login; please retry" and logs a throttled capacity warning server-side.

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. A JWKS key below the minimum strength floor — RSA under 2048 bits, or an EC key off the approved NIST P-256/384/521 curves — is skipped like a malformed key, so a token signed by an under-strength key has no key to resolve against and fails closed. The same floor gates the SAML signing certificate, so the two paths cannot drift.
  • 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 OpenID Connect id_token requirements below.

OpenID Connect id_token requirements

The provider-facing companion to the validation above: what your OpenID provider must be configured to satisfy, plus the account-adoption gates and the upgrade / migration runbook.

The plugin validates the id_token returned by your OpenID provider (signature, issuer, audience and expiry). A few provider settings must therefore match, or login is refused (fail-closed):

  • The id_token must be signed with an asymmetric algorithm — RS256/384/512, PS256/384/512, or ES256/384/512. Tokens signed with a symmetric algorithm (HS256) or left unsigned (alg: none) are rejected regardless of configuration. If your IdP client defaults to HS256 (some Auth0 and Keycloak client templates do), switch its id_token signing algorithm to RS256. EdDSA/Ed25519 (OKP keys) is not supported.

  • The provider must publish a JWKS (its discovery document's jwks_uri) so the signature can be verified. A rotated signing key is picked up automatically on the next login.

  • Clocks must be roughly in sync. Expiry is checked with a 5-minute skew allowance; if the Jellyfin host or the IdP clock drifts further, every login fails — keep both on NTP.

  • Issuer is matched against the discovery issuer. If your IdP legitimately presents a different issuer (some templated or multi-tenant setups), set DoNotValidateIssuerName: true for that provider — this relaxes only the issuer check; signature, audience and expiry stay enforced.

  • Authorization-response issuer (RFC 9207). When the provider adds an iss parameter to the authorization response (a mix-up defense), it is checked against the authorization server's issuer — its discovery issuer (RFC 9207 §2.4) or the id_token issuer — and a value matching neither is rejected. Accepting the id_token issuer too keeps templated / multi-tenant providers working under DoNotValidateIssuerName, where the response iss equals the concrete id_token issuer rather than the templated discovery issuer. If the provider's discovery document advertises authorization_response_iss_parameter_supported (RFC 9207 §2.4), a missing iss is treated as a downgrade and rejected too; providers that do not advertise it and omit iss are unaffected. If a provider legitimately sends a different iss there, set DoNotValidateResponseIssuer: true for that provider to relax only this check.

  • PKCE (RFC 9700 §2.1.1). The plugin always sends a PKCE code_challenge (S256), but a server that ignores PKCE would silently downgrade cross-session authorization-code-injection protection. On login the provider's discovery document is checked for S256 in code_challenge_methods_supported; if it is absent, an [SSO Audit] warning is logged and the login still proceeds. Set RequirePkce: true for a provider to fail the login closed instead when S256 is not advertised (or the discovery document cannot be read).

  • If your IdP sends an at_hash claim it must match the issued access token — a correctly behaving provider always satisfies this.

  • A sub claim is required, and it must be stable. The account link is keyed on the immutable sub, not on the (mutable) username — so renaming a user at the identity provider keeps their Jellyfin account linked, and a recycled username cannot inherit another user's account. A provider that mints a different sub for the same user on every login (a misconfigured pairwise-subject setup) will work exactly once and then be refused — fix the IdP configuration; there is nothing safe to anchor the identity to otherwise.

  • Account links are bound to the issuer that created them (repointing protection). Each OpenID canonical link records the id_token iss it was minted under, so a link is the full OIDC-recommended (iss, sub) pair, not a bare sub. This matters when a provider entry is repointed at a different identity provider — because a sub is only unique within an issuer, a new IdP that mints a short numeric subject like 1 could otherwise collide with an accumulated link and silently sign the new user in to the old user's account. Two independent guards close that:

    • Login-time issuer check. On every login the resolved link's stored issuer is compared to the login's issuer, and a mismatch refuses the login (fail closed). This catches a swapped IdP even behind an unchanged discovery URL (a DNS/backend repoint), which nothing else can see. It is self-healing: re-establish the link with the admin linking endpoint (AddCanonicalLink), or clear the stale links, and logins resume.
    • Clear-on-endpoint-change. Changing a provider's OidEndpoint (through the admin form or OID/Add) drops that provider's accumulated links and their issuer bindings, treating the URL change as a provider re-identification — the same way the stored client secret is dropped on an endpoint change. This also protects links that predate this feature (see migration below) against a repoint. So an endpoint edit is a reconfiguration: users re-link on their next login (or via the admin endpoint), just as you must re-enter the client secret. The comparison is exact (ordinal), so even a benign normalization — adding a trailing slash, changing host case — clears the links. Plan a re-link window when you change an endpoint: with AllowExistingAccountLink on, the next login wave re-adopts each account by name and re-stamps the links automatically (treat it as the short, controlled window described below, then turn the flag back off); with AllowExistingAccountLink off, every returning user instead hits the "an account already exists" refusal (HTTP 403) until you re-link them with AddCanonicalLink or open that brief adoption window. If the URL edit was cosmetic and you did not intend to re-identify the provider, prefer leaving the endpoint string untouched.

    Migration / compatibility. Links created before this feature carry no stored issuer. They keep working unchanged while the endpoint is unchanged, and are stamped with the current issuer on the next successful login (trust-on-first-use) — no userbase lockout on upgrade. One accepted, narrow residual (the issue itself calls it that, not a regression): if a provider was repointed at a new IdP before you upgraded to a version with this feature, an un-stamped legacy link whose sub collides is stamped with the new issuer on first login rather than being caught; the clear-on-endpoint-change guard covers every repoint from the upgrade onward.

  • DoNotValidateIssuerName relaxes the issuer binding. With this escape hatch on, the id_token's iss is not cross-checked against the discovery location, so the value bound to the link is whatever the signed token asserts (still signed by the discovered keyset, but not tied to the configured endpoint). The binding is still enforced login-to-login; it just trusts the token's self-declared issuer. Enabling it is recorded in the [SSO Audit] log like the other relaxations — prefer it only for genuinely templated / multi-tenant providers.

  • Adopting a same-named pre-existing account is gated (AllowExistingAccountLink). When a first login finds no link but a Jellyfin account already bears the SSO name, the account is adopted only if the provider has AllowExistingAccountLink set — otherwise the login is refused (HTTP 403). Adoption matches on the mutable display name (preferred_username / SAML NameID), so enabling it trusts the identity provider to make usernames unique and non-reassignable; a provider that lets a new principal assert an existing user's name would otherwise hand that account over. Two fail-closed gates narrow that trust (#218):

    • An administrator account is never adopted by name — regardless of any setting or protocol. An admin account is the highest-value takeover target, so name-based adoption of one is always refused (a WARNING is logged). Link an admin account to its SSO identity explicitly instead, via the admin linking endpoint (AddCanonicalLink), which needs no name trust.
    • RequireVerifiedEmailForAdoption (OpenID only, off by default). Set it on a provider to additionally require the login to carry email_verified == true before adopting a same-named (non-admin) account; an absent or false claim is refused. This raises the bar from "asserts a name" to "asserts a name and holds a provider-verified email". Jellyfin accounts store no email to cross-check against, so this does not match the email to the target account — it does not replace the unique-username assumption, it narrows who can exploit it. It is off by default so a deployment already relying on AllowExistingAccountLink is not silently locked out on upgrade. Enabling it needs the email scope in the provider's OidScopes so the IdP actually returns email_verified; without that scope the claim is absent and every adoption is refused. SAML has no email_verified claim, so this gate is not applicable there — only the admin refusal above applies, and SAML operators relying on name-based adoption should ensure their IdP issues stable, non-reassignable NameIDs. The transitional legacy re-key path below is exempt from the verified-email requirement (it continues a relationship established under the old scheme, not a new one), so on that one path the admin-takeover ceiling is still enforced but this verified-email defense-in-depth is not. Both AllowExistingAccountLink and RequireVerifiedEmailForAdoption are settable in the admin OpenID provider form, or by editing the provider's config.xml directly.
  • Requiring a verified email for the login itself (RequireVerifiedEmailForLogin, OpenID only, off by default). The adoption gate above only guards the one moment a login adopts a same-named account. Set RequireVerifiedEmailForLogin on a provider to instead require every OpenID login for that provider to carry email_verified == true — whether it adopts, creates, or signs in to an already-linked account. A login whose email_verified is not exactly true (absent, false, or unparseable) is refused (HTTP 403). Use it when the provider allows unverified emails and you do not want such accounts to sign in at all. It is off by default so a deployment that does not set it — or an IdP that never emits the claim — is unaffected on upgrade; and it needs the email scope in the provider's OidScopes, otherwise the claim is absent and every login is refused. It is independent of AllowExistingAccountLink / RequireVerifiedEmailForAdoption (you can run either, both, or neither). SAML carries no email_verified claim, so this gate does not apply there. RequireVerifiedEmailForLogin is settable in the admin OpenID provider form, or by editing the provider's config.xml directly.

  • Upgrading from a username-keyed version — read this before you upgrade. Links created by older plugin versions (up to and including 4.0.0.4) are keyed on the username. A username is something the identity provider can reassign, so following such a link is name-based account matching, governed by the same AllowExistingAccountLink opt-in as adopting a same-named account. Because 4.0.0.4 keyed every OpenID link on the username and predates the flag (it deserializes to false), a straight upgrade breaks OpenID sign-in for your whole userbase until you migrate — though not identically for everyone: with the flag off the legacy link is not followed, so a user whose name still matches their account is refused (HTTP 403), while a user whose name was freed by a rename silently lands on a fresh, empty account instead of their own (a successful login, not a 403). Either way no OpenID user reaches their existing account. This is deliberate: with only a sub and a mutable preferred_username to go on, the plugin cannot tell a returning user from an attacker who set their preferred_username to a victim's name, so it fails closed. Plan the migration:

    1. Keep a break-glass admin that does not depend on SSO. SSO-provisioned accounts get a random password, so if your only admins sign in through OpenID they will be locked out by the 403 above with no way back in-band. Before upgrading, make sure at least one Jellyfin administrator has a normal password login (Dashboard → Users), or you will be left editing config on disk (next point). Note the extra admin case (#218): a returning administrator who held a pre-#155 username-keyed OpenID link is refused on their first post-upgrade login even with AllowExistingAccountLink on — an admin account is never adopted or re-keyed by name — so re-link it explicitly with AddCanonicalLink (from the break-glass admin) rather than expecting self-migration. If that admin was itself originally plugin-created and is your only admin (random password, signs in through SSO), recover server-side: reset its password from the Jellyfin dashboard using another admin, or — if truly locked out — link it by editing the provider's <CanonicalLinks> in config.xml to map the current sub to that user id, then restart.
    2. Fully locked out? Stop Jellyfin, open the plugin's config.xml in its data directory, set <AllowExistingAccountLink>true</AllowExistingAccountLink> inside the affected provider's config block, restart, and complete the migration below; then set it back to false.
    3. Migrate. The safest route is to link each account explicitly via the admin API (AddCanonicalLink) — it needs no name trust. If instead you enable AllowExistingAccountLink to let logins self-migrate, treat it as a maintenance window, not a standing setting: while it is on, that provider is back to trusting preferred_username, so whoever logs in first with a name a live account currently bears claims that account irreversibly (an attacker who knows a victim's current username can take that account first). The flag now self-migrates a legacy link only while the recorded account still bears that name (#361), so a name already renamed away no longer reaches the old account — but any name a live account currently holds is still fair game. Keep the window short and controlled, migrate your users in it, and turn the flag back off immediately.
    4. Renamed accounts are admin-recovery only. A legacy link is keyed on the username as it was when the link was created. Enabling the flag self-migrates a legacy link only while the account it points at still bears that name (#361), so it recovers the common case — a user whose name never changed — but not an account that has since been renamed, on either side:
      • Jellyfin-side rename (the account was renamed, but the identity provider still sends the original name): the recorded target no longer bears that name, so enabling AllowExistingAccountLink does not migrate it — and must not, since following a name the account no longer holds is exactly the stale-name takeover #361 closed.
      • IdP-side rename (the provider now sends a different name than the legacy key, so the recorded key is never matched), or a user who has already logged in without the flag and now sits on a fresh sub-keyed account (that link wins and the old entry is never consulted again). For all of these, recover by hand — as admin, delete any fresh account's link (DeleteCanonicalLink) and AddCanonicalLink the original account to the user's current sub.

    A server log line (WARNING) marks a login that carries a pending legacy link and is either refused (flag off, a live account still bears the name) or lands on a fresh account (no live account bears the name, so the original is orphaned — now under the flag on as well as off, #361; the warning is worded to flag this case explicitly, since no 403 accompanies it). A flag-on login that instead adopts a different account currently bearing the name is recorded in the audit log as an ordinary adoption, not by this warning. Watch the refuse/orphan warnings so you can see who still needs migrating. Note that the self-service linking page now lists OpenID links by their sub value, which for many providers is an opaque identifier rather than a readable name. If you run the anonymous SSO endpoints with rate limiting available, enable it during the window to blunt an attacker probing names while the flag is on.

OpenID authorization-response issuer (RFC 9207)

When the provider adds an iss parameter to the authorization response (a mix-up defense, RFC 9207), it must match the issuer of the validated id_token the code was redeemed for; a mismatch means the response came from a different authorization server than the one the token belongs to, and the login is rejected. Providers that do not send iss are unaffected. The per-provider DoNotValidateResponseIssuer escape hatch relaxes only this check.

PKCE downgrade detection

The plugin always sends a PKCE code_challenge (S256) with the authorization request. PKCE only protects against authorization-code injection when the server actually enforces it, so on login the provider's discovery document is checked for S256 in code_challenge_methods_supported (RFC 9700 §2.1.1); the result is cached for about 15 minutes. By default a provider that does not advertise it logs an [SSO Audit] warning and the login proceeds — PKCE is still sent, but the server may be ignoring it. Setting Require PKCE (S256) (RequirePkce) on a provider fails closed instead: the login is refused when S256 is not advertised, or when the discovery document cannot be read to confirm it.

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.

The page also carries a strict Content-Security-Policy: default-src 'none', with only the page's own inline script and style authorized via a per-response nonce. Network access is limited to the page's own origin (connect-src 'self' for the session-mint call, frame-src 'self' for the bootstrap iframe, images same-origin or data:), and base-uri, form-action, and frame-ancestors are all denied. Markup injected into the page therefore cannot execute script, re-base its URLs, or send the token anywhere but back to this server.

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. IPv4-in-IPv6 transition addresses (the NAT64 well-known prefix, 6to4, IPv4-compatible) are keyed on their embedded IPv4 instead of the shared transition prefix, so distinct clients translated through one NAT64 prefix each get their own bucket — one abuser behind the prefix cannot throttle everyone else behind it.

When throttling engages, the server log carries a bounded notice: a single warning line with the count of requests refused since the last notice, at most once per minute and never including a client address. That makes a sustained brute-force attempt — or a reverse proxy misconfigured so that every client pools into one bucket — visible in the log without the notice itself amplifying the flood into log volume.

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.

Secrets encrypted at rest

The OpenID client secret (OidSecret) and the SAML request-signing key (SamlSigningKeyPfx) are stored in the configuration XML as an AES-256-GCM ssoenc:v1: envelope, not plaintext, so a leaked config file alone does not reveal them. The data-encryption key lives in a separate sso-secret.key file in the plugin data folder (owner-only permissions on Linux); it is created on first save, not at startup, and must be backed up alongside the config — without it, every encrypted secret is permanently unrecoverable, and the plugin fails closed (refuses a signed SAML login or an OpenID token exchange) rather than falling back to an empty or wrong secret. An existing plaintext config keeps working unchanged and is rewritten as an envelope the next time it is saved; no manual migration step is needed.

This is a breaking change on downgrade. A plugin version without this change cannot read ssoenc: values, so rolling back after a secret has been encrypted leaves the affected provider looking like its secret is unset. To roll back safely:

  1. Before downgrading, open each affected provider on the settings page and re-enter its client secret / re-upload its signing key, then save — or keep a backup of the pre-upgrade (plaintext) config XML.
  2. Install the older plugin version and restore that plaintext config (or re-enter the secrets on the older version's settings page).
  3. The sso-secret.key file is ignored by older versions and can be left in place or removed.

SSO-only login (opt-in)

The optional SSO-only mode (DisablePasswordLogin) routes every account off Jellyfin's built-in password provider so users can only sign in through SSO — except one designated break-glass administrator, whose password login is preserved on every path as the IdP-independent recovery door. The security properties:

  • Fail-closed last-admin guard. Activation is refused — with a single, non-enumerating message, and without changing anything — unless the break-glass account is an existing, enabled administrator that still has a usable password login. An SSO-linked admin does not qualify: its usability depends on the IdP being up, which is exactly what fails in the lockout scenario. If the enforcement sweep itself cannot run, the mode is rolled back rather than left recorded-on but unenforced.
  • The break-glass door survives every path. The recovery admin is never swept or repointed (even when a provider's DefaultProvider targets SSO), and while the mode is on no SSO login or role mapping can demote it from administrator or disable it. Role mappings can never grant IsDisabled to any account.
  • Un-forgeable state. DisablePasswordLogin, BreakGlassAdminUsername, and the tracked restore list are server-managed: a settings-page save, config PUT, or config import cannot flip the mode or move the designation — only the dedicated elevation-gated SSO-Only/Enable|Disable|BreakGlassAdmin endpoints (and the documented on-disk recovery edit) change them.
  • Reversible without SSO. Disabling — via the endpoint, or by editing the config XML and restarting (a boot reconciliation restores the swept accounts) — restores password routing only, for exactly the accounts the mode moved. No password hash is ever reset or revealed, and natively-SSO plugin-created accounts are never handed a password door.

The operator runbook is below; the threat model is recorded in SSO-Only Login Design.

Operator runbook: enable, disable, status, and recovery

The plugin settings page (Dashboard → Plugins → SSO-Auth) describes the mode, but saving the settings page never changes the SSO-only state. The mode, the break-glass designation, and the off-switch are driven only by four dedicated endpoints, all of which require administrator privileges:

GET  <base URL>/sso/SSO-Only/Status
POST <base URL>/sso/SSO-Only/Enable            (JSON body: the break-glass admin username)
POST <base URL>/sso/SSO-Only/Disable
POST <base URL>/sso/SSO-Only/BreakGlassAdmin   (JSON body: the new break-glass admin username)

For example, with an admin API key (pass it in the header, not the query string):

curl -X POST -H 'Authorization: MediaBrowser Token="YOUR_KEY"' \
  -H 'Content-Type: application/json' -d '"breakglass-admin"' \
  https://jellyfin.example.com/sso/SSO-Only/Enable
  • Status returns the mode, the designated username, and a live GuardSatisfied flag — check it after any account change, because it re-evaluates the guard against the current account state and turns false if the break-glass admin has since been deleted, demoted, disabled, or lost its password.
  • Enable runs the guard first, then designates the break-glass admin and sweeps every other password-provider account onto SSO routing in one step.
  • Disable turns the mode off and restores password routing for exactly the accounts the mode moved. Accounts the plugin itself created for SSO users are natively SSO-routed and are never handed a password door by the restore.
  • BreakGlassAdmin re-designates the recovery account; the new target must itself satisfy the guard. While the mode is on, no other account can qualify (every other admin has already been swept off the password provider) — so to hand the role over, disable the mode, re-designate, then re-enable.

These three settings (DisablePasswordLogin, BreakGlassAdminUsername, and the internal list of swept accounts) are server-managed: a settings-page save, a config PUT, or a config import can never flip the mode, move the designation, or alter the restore list — only the endpoints above (and the on-disk recovery edit below) change them. Every enable, disable, and refused activation is written to the [SSO Audit] log.

Recovery — two independent doors, neither of which depends on the IdP:

  1. Break-glass login. The designated admin signs in with username and password as normal and either fixes the provider or calls SSO-Only/Disable.
  2. Total lockout (no working break-glass login). On the server, edit the plugin's configuration XML (<jellyfin data dir>/plugins/configurations/Jellyfin.Plugin.SSO_Auth.xml), set <DisablePasswordLogin> to false, and restart Jellyfin. A boot-time reconciliation then restores password routing for every account the mode had moved, so this recovery genuinely works even though the enforcement lives in the user database rather than the flag.

Audit trail

Security-relevant events — successful logins, opt-in adoption of an existing unlinked account, admin provider add/remove, and SSO-only mode transitions (enables, disables, and refused activations, by reason code) — 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