-
-
Notifications
You must be signed in to change notification settings - Fork 0
Login Flow
This page walks through what actually happens, end to end, when someone signs in to Jellyfin through this plugin — from the moment they click a provider button to the moment Jellyfin hands them a session. It describes both protocols, OpenID Connect and SAML 2.0, as they are implemented today, so you can follow the flow without reading the source. Where a step exists to keep the login path fail-closed, this page says so and links to the Security Model, which is the authoritative place for why each check is there.
If a step here disagrees with the code, the code wins — please open an issue.
OpenID and SAML differ in the messages on the wire, but the plugin drives them through the same four stages:
- Challenge — the browser asks the plugin to start a login for a named provider. The plugin builds the protocol-specific request and redirects the browser to the identity provider (IdP).
- Identity provider — the user authenticates at the IdP (password, MFA, consent — entirely the IdP's concern). The IdP sends the browser back to the plugin's callback with a signed result.
- Callback — the plugin validates that result fail-closed and, if it passes, renders a small auth page that runs in the browser.
- Session mint — the auth page posts the validated result back to the plugin, which resolves the Jellyfin account, applies the granted permissions, and asks Jellyfin to issue a session token. The browser stores the token and lands in the web UI, signed in.
The split between stage 3 (callback) and stage 4 (session mint) exists because the browser redirect that returns from the IdP cannot carry the client's device identity. The auth page bridges that: it first bootstraps the Jellyfin client credentials, then makes the final, authenticated call that actually creates the session.
All the anonymous endpoints below — challenge, callback, and the session-mint call — sit behind the optional per-client rate limiter.
sequenceDiagram
participant B as Browser
participant P as SSO plugin
participant I as Identity provider
B->>P: GET /sso/OID/start/{provider}
Note over P: rate-limit check<br/>PKCE-S256 discovery gate<br/>prepare login (PKCE, state, nonce)<br/>store authorize state (provider-bound)
P-->>B: 302 to IdP authorize URL
B->>I: authenticate (login / MFA / consent)
I-->>B: 302 to /sso/OID/redirect/{provider}?code&state&iss
B->>P: GET callback
Note over P: look up state (unexpired, same provider)<br/>redeem code, validate id_token<br/>(signature / iss / aud / exp)<br/>RFC 9207 iss match<br/>resolve stable sub, apply role allow-list
P-->>B: auth page (HTML) if the login is permitted
Note over B: bootstrap Jellyfin credentials + device id
B->>P: POST /sso/OID/Auth/{provider} { data: state, device info }
Note over P: claim state (single-use)<br/>resolve canonical link on sub<br/>create / adopt account<br/>apply permissions, mint session
P-->>B: AuthenticationResult (access token)
Note over B: store token, redirect to /web
- Optionally throttled by the rate limiter.
-
PKCE downgrade gate (#141). The plugin always sends a PKCE
code_challengewith the S256 method, but PKCE only protects against authorization-code injection if the server enforces it. So the provider's discovery document is fetched and checked forS256incode_challenge_methods_supported. By default a provider that does not advertise it logs an[SSO Audit]warning and the login proceeds; with Require PKCE (S256) (RequirePkce) set on the provider, the login is instead refused when S256 is not advertised, or when discovery cannot be read to confirm it. The result is cached briefly so a login does not fetch discovery every time. See PKCE downgrade detection. - The plugin prepares the login (this generates the PKCE verifier, the CSPRNG state token, and the nonce) and builds the redirect URI back to its own callback.
- The in-flight authorize state is stored keyed by that state token, tagged with the provider that minted it and whether this is a linking request. The store is bounded and provider-bound; see OpenID authorize state.
- The plugin redirects the browser to the provider's authorize URL.
The user authenticates at the IdP. Nothing on the plugin side runs during this step; the
authorize state simply waits (for up to about 15 minutes, long enough for an MFA or consent
step). The IdP then redirects the browser back to the callback with an authorization code,
the original state, and — from providers that implement RFC 9207 — an iss parameter.
- The
statequery value must match a stored authorize state that is not expired and was minted for this same provider; otherwise the callback is rejected. This stops a state issued for one provider from being validated on another's callback. - The plugin redeems the authorization
codefor tokens. The returned id_token is validated fail-closed before any identity is read from it: its signature is verified against the provider's published JWKS, and its issuer, audience/azp, and lifetime are checked. Only asymmetric signature algorithms are accepted (RS/PS/ES 256/384/512); a symmetric (HS*) or unsigned (alg: none) token is rejected regardless of configuration. See OpenID id_token validation. -
RFC 9207 mix-up check. If the authorization response carried an
issparameter, it must name the same issuer as the validated id_token; a mismatch means the response came from a different authorization server than the one the token belongs to, and the login is rejected. See authorization-response issuer. -
Stable subject (#155). The login must resolve an OpenID
subclaim, which is the stable identifier the account link is keyed on.subis a required OIDC claim, so a missing one means a non-conformant provider and the login is refused rather than falling back to the mutable username. - The user's roles and the provider's allow-list decide whether the login is permitted and what it grants (see Role and permission mapping below). A permitted login's derived values (username, admin, Live TV, folders, avatar URL) are stored on the authorize state.
- If the login is permitted, the plugin returns the auth page (a small HTML document, served with hardening headers and a strict Content-Security-Policy — see Response hardening). If not, it returns a uniform denial that does not reveal why.
The auth page runs in the browser and:
- loads the Jellyfin web app in a hidden, sandboxed iframe to bootstrap the client
credentials and device id in
localStorage; - posts
{ data: <state token>, deviceId, appName, appVersion, deviceName }back to the session-mint endpoint.
The plugin then:
- looks the state up by that token and atomically claims it (removes it), so one state mints at most one session even under concurrent posts;
- resolves the canonical account link on the
sub(see Account linking below), creating or adopting the Jellyfin account as policy allows; - applies the granted permissions, optionally fetches the avatar (SSRF-guarded — see avatar fetch), and asks Jellyfin to authenticate the client, recording the client's source IP the same way a password login does;
- writes an
[SSO Audit]success entry and returns Jellyfin'sAuthenticationResult.
The auth page stores the returned access token in localStorage and redirects to
/web/index.html, now signed in.
sequenceDiagram
participant B as Browser
participant P as SSO plugin
participant I as Identity provider
B->>P: GET /sso/SAML/start/{provider}
Note over P: rate-limit check<br/>build AuthnRequest<br/>remember request id (solicited-only, opt-in)
P-->>B: 302 to IdP SSO URL (SAMLRequest)
B->>I: authenticate (login / MFA / consent)
I-->>B: POST SAMLResponse to /sso/SAML/post/{provider}
B->>P: POST callback (form SAMLResponse)
Note over P: parse (DTD prohibited), validate signature<br/>time bounds, audience, recipient<br/>apply role allow-list
P-->>B: auth page (HTML) if the login is permitted
Note over B: bootstrap Jellyfin credentials + device id
B->>P: POST /sso/SAML/Auth/{provider} { data: response XML, device info }
Note over P: InResponseTo correlation (opt-in)<br/>one-time-use replay check<br/>resolve canonical link on NameID<br/>create / adopt account, mint session
P-->>B: AuthenticationResult (access token)
Note over B: store token, redirect to /web
- Optionally throttled by the rate limiter.
- The plugin builds a SAML
AuthnRequestfor the configured SP entity id, with its own assertion-consumer URL as the reply address. A linking request setsRelayStatetolinking. -
Solicited-only mode (#156, opt-in). With
ValidateInResponseToset, the plugin remembers the request's id (scoped by provider) so the callback can later require the response'sInResponseToto match a request this server actually issued. This is registered for login flows only, not for the linking flow. - The plugin redirects the browser to the IdP's SSO URL with the encoded
SAMLRequest.
The user authenticates at the IdP, which then POSTs a signed SAMLResponse back to the
plugin's assertion-consumer URL (SAML's HTTP-POST binding — this is why the callback is a
POST, unlike OpenID's redirect).
The SAMLResponse form field is parsed and validated fail-closed. The response is accepted
only when every check holds; any failure returns a uniform rejection:
- Bounded input — an oversized Base64 body is rejected before any decoding.
-
XML parsing — DTD processing is prohibited, so any
<!DOCTYPE>is rejected up front (this closes XXE and entity-expansion attacks). - Signature — exactly one signature, covering the element that is actually consumed (this blocks XML-signature-wrapping); SHA-1 is rejected.
-
Time bounds —
NotOnOrAfter(andNotBeforewhen present) are enforced with a bounded clock skew; a missing or unparseable bound is rejected. -
Audience — the assertion's
AudienceRestrictionmust name this service provider (opt-out available). -
Recipient/Destination (#156, opt-in) — with
ValidateRecipientset, the signed Recipient (or Response Destination) must equal one of this server's assertion-consumer URLs, so an assertion minted for a different endpoint cannot be presented here.
See SAML response validation for the detail. If the assertion's roles satisfy the login allow-list, the plugin returns the auth page carrying the signed response; otherwise it returns a uniform denial.
As on the OpenID side, the auth page bootstraps the client credentials and posts the response back. The session-mint endpoint re-validates the response (it never trusts that the page ran the checks — a caller can POST straight here and skip the page) and then:
-
Solicited-only correlation (#156). With
ValidateInResponseToset, the response'sInResponseTois consumed against a request this server issued; an unsolicited (IdP-initiated) response carries none and is refused, and a matching id is one-time-use. - Login allow-list is enforced here too.
- One-time use (#219). The consumed assertion id is claimed against a per-provider replay cache, so a captured assertion cannot be replayed to mint a second session.
-
NameID (#95). The assertion must resolve a usable
NameID; SAML keys the account link directly on it (it is already the stable identifier — key and account name are the same value). - The plugin resolves the canonical account link on the
NameID, applies the role-derived permissions, writes an[SSO Audit]success entry, and asks Jellyfin to authenticate the client.
On both protocols the user's roles/groups — OpenID role claims, or the SAML Role
attribute — are compared against per-provider allow-lists to decide the login and its
privileges. Matching is exact (ordinal) and grants are additive (a role can grant a
privilege, never take one away):
| Grant | Configuration | Effect |
|---|---|---|
| Login | Roles |
When set, the user must carry at least one listed role to sign in. When empty, any authenticated user is allowed (RBAC off). |
| Administrator | AdminRoles |
A listed role grants Jellyfin administrator rights. |
| Library folders |
EnableFolderRoles + folder-role mapping, or the statically EnabledFolders set |
Grants access to specific libraries. |
| Live TV |
EnableLiveTvRoles + LiveTvRoles / LiveTvManagementRoles
|
Grants Live TV access and/or management. |
On OpenID, the username is taken from the preferred_username claim (or a configured
DefaultUsernameClaim), falling back to sub; on SAML it is the NameID. The optional
avatar URL is built from claim values against a configured format. All of these permission
grants are only written to the account when the provider's EnableAuthorization is on;
with it off, the plugin authenticates the user but does not manage their permissions.
An SSO login is bound to the IdP's stable identifier — OpenID sub / SAML NameID —
recorded per provider as a canonical link to a Jellyfin user id. On each login the
plugin:
- resolves the link by that stable key. If a link exists, that account is used;
- otherwise, if a Jellyfin account with the same name already exists but is unlinked,
the login is refused (HTTP 403) by default — a same-named account is not silently
adopted — unless the provider's
AllowExistingAccountLinkis enabled, in which case the account is adopted and linked; - otherwise a new account is created with a random password and linked.
The write is atomic under concurrency, so two simultaneous first logins for the same identity converge on one account rather than both creating one. This is the core anti-account-takeover control; see Identity binding.
A signed-in user can link an SSO identity to their existing Jellyfin account from
/SSOViews/linking without an administrator. The linking variant runs the same challenge →
IdP → callback path (RelayState = linking on SAML; isLinking on OpenID), but the auth
page calls POST /sso/{mode}/Link/{provider}/{jellyfinUserId} with the user's own token
instead of minting a session. The endpoint requires the caller to be that user (or an
admin), and the SAML linking callback enforces the same one-time-use replay check as a
login. An administrator can later revoke a user's SSO access with
POST /sso/Unregister/<username>, which removes that user's canonical links across every
provider.
The redirect-and-auth-page flow above runs in a web browser — the Jellyfin Web UI. A native client (mobile or TV app) that cannot run the callback page completes SSO through Jellyfin's own Quick Connect instead: the client displays a Quick Connect code, the user signs in through SSO in a browser and approves that code, and Jellyfin hands the authenticated session back to the waiting client. Quick Connect is Jellyfin's built-in mechanism; the plugin's role is simply to make the browser login SSO-capable. A native client that supports neither the browser flow nor Quick Connect cannot complete an SSO login — use the Web UI or a Quick Connect–capable client.
- Security Model — why each validation step is there, and how the login path fails closed.
- Architecture Internals — the as-built code-fact map: the concrete types and methods each step above runs through, and where they live.
- Provider Setup — per-identity-provider setup, including the id_token requirements.
- Troubleshooting — what a specific rejection at any of these steps means.
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)