-
-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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. 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 the
Provider Guides.
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
Responseor the singleAssertion). 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 bounds —
NotOnOrAfter(andNotBeforewhen 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
AudienceRestrictionmust 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.
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. The response must additionally 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 it is later served executably. The avatar is best-effort: a refused avatar is logged and skipped, the login itself proceeds.
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.
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
DoNotValidateIssuerNameescape hatch relaxes only this check; signature, audience and expiry validation have no off switch. -
Audience — must include this client. When an
azpclaim is present it must equal the client id, and a multi-audience token withoutazpis rejected — it may have been minted for a different party that merely lists this client as a co-audience. -
Lifetime —
exp(andnbfwhen present) are enforced with a bounded clock skew. - When the provider sends an
at_hashclaim, it must match the issued access token.
What your provider must be configured to satisfy is described in the id_token requirements.
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.
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.
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.
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.
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.
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).
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.
Repository · Issues · Releases · Security policy — report vulnerabilities privately, never in a public issue. Pages describe what is implemented today; if the wiki disagrees with the code, the code wins.
Getting started
- Installation
- Provider Setup
- Hardening & Options Reference
- Migrating from 9p4
- Troubleshooting
- Rollback
How it works
Security
- Security Model
- Security Conformance (ASVS / RFC 9700)
- Threat Model
- SSO-Only Login — design record
- Single Logout — design record
Standards & process (internal / maintainer)