A Jellyfin plugin that adds native two-factor authentication (TOTP, email OTP) with trusted device tokens, TV device pairing, LAN bypass, and API key bypass. Server-side enforcement — works with all clients including web, mobile, TV, and service integrations like Sonarr/Radarr.
C#
285
401 commits
updated Oct 1, 2026
██████╗ ███████╗ █████╗
╚════██╗██╔════╝██╔══██╗
█████╔╝█████╗ ███████║
██╔═══╝ ██╔══╝ ██╔══██║
███████╗██║ ██║ ██║
╚══════╝╚═╝ ╚═╝ ╚═╝
Comprehensive authentication and hardening for Jellyfin: TOTP, passkeys, email OTP, OIDC/SSO sign-in, brute-force IP banning, impossible-travel detection, per-user IP allowlist, device pairing, trusted-browser cookies, and a full audit log - all from one plugin.
Why this exists: for self-hosters who want a complete auth + hardening layer without standing up a separate identity stack. Full IdPs like Authentik (with OIDC or LDAP outposts) and Authelia work great with Jellyfin and offer features this plugin doesn't - they're often the right call for serious deployments. This plugin is for the case where you'd rather get TOTP, passkeys, OIDC sign-in, brute-force protection, impossible-travel detection, IP allowlist, audit logging, and a proper admin UI as a single Jellyfin plugin — no extra containers, no LDAP outpost, no proxy-auth header juggling, native Jellyfin user model end-to-end.
📖 New: step-by-step guides live in the Wiki — Installation, First-Time Setup, OIDC / SSO, Account Protection, Admin Guide, and Troubleshooting.
You don't have to take my word for it. Every signal below is automated and visible to anyone, including you:
security-extended + security-and-quality C# query packs on every push, PR, and weekly. Green = no security findings..md5 and .sha256 files alongside the .zip so you can verify the artifact wasn't tampered with after upload.If any of these go red, file an issue or DM @zack154 on Discord — fixing
visible trust signals is treated as a high-priority bug.
No more lockouts, and Jellyfin 12 sign-in is finished. Since v2.6.0 the plugin gained a safety net so that uninstalling, disabling, or losing it never strands the accounts that used it; finished OIDC sign-in for TVs and other keyboard-less devices; wired up the GeoIP alerts; and cleared the OIDC→2FA and libsodium crash reports. In-place upgrade from any 2.5.x or 2.6.x, no schema migration or config reset.
v2.6.3
Also since v2.6.0
redirect_uri, pairing QRs and reset links use the right public host, and a device that completed a second-screen approval can optionally be remembered (opt-in, off by default). (v2.6.2)libsodium core dumps (#203/#212, camarigor) — the recovery-code PDF path no longer drags in the wrong native library, and the correct one is repaired for the host's architecture at load. (v2.6.1)Full version history is in the Changelog below and on GitHub Releases.
/TwoFactorAuth/Setup — scans a QR code with an authenticator app and saves recovery codes.SessionStarted event fires. The plugin checks if the user has 2FA enabled./TwoFactorAuth/Login.__2fa_trust cookie is set in the browser. For 30 days, that browser doesn't need 2FA again — but new browsers/devices still do.The standard Jellyfin login page gets a small "Sign in with 2FA" button injected so users with 2FA enrolled can route directly to the plugin's login form. The injected controls are Base URL-aware and are restored after Jellyfin's single-page navigation, including the official Android web shell.
Organized by capability. Per-version history lives in the Changelog and on GitHub Releases.
targetAbi 10.11.0.0) and a .NET 10 build (targetAbi 12.0.0.0) are published under the same catalog entry, and Jellyfin installs the one matching your server automatically. Existing installs auto-update, and upgrading a server from 10.11 to 12 switches builds on the next update.linux-x64, linux-arm64, and linux-musl-x64 native libraries are bundled, so recovery-code PDFs and native crypto work on x86, Raspberry Pi, Apple-Silicon Linux, and Alpine.Authorization: MediaBrowser Token, required once 12 disables legacy authorization.SameSite=Strict).realm_access / resource_access roles.redirect_uri, pairing QRs, and password-reset email link use the server's real public host rather than the address a direct/LAN client reached it on.prompt=login" toggle for IdPs that reject forced re-auth.ISessionManager.SessionStarted — works for all clients, not just web.Off / Destructive / AllConfigChanges / Everything) re-prompts for 2FA on sensitive admin actions, covering the OIDC provider form and every gated admin call.CryptographicOperations.FixedTimeEquals).BareDeviceIdBypassEnabled turned on, which is off by default (see First-time setup).PairDeviceOnSecondScreenApproval, off by default): a sign-in someone approved on another screen, the OIDC login QR on a TV or a Quick Connect code, adds that device to the user's paired devices instead of forgetting it. The record is visible and revocable on the Setup page, and it only waives 2FA where the bare device ID bypass is enabled.

Admin overview — a live security-posture score with an actionable breakdown of every hardening factor.
| Sign in — SSO / 2FA / passkey | Verify your identity (2FA) |
|---|---|
![]() | ![]() |
| Authenticator (TOTP) enrollment | 2FA login portal |
![]() | ![]() |
| Per-user 2FA management | Login audit log |
![]() | ![]() |
| OIDC / SSO providers | Settings |
![]() | ![]() |
Every screen is fully translated across 8 languages (example — German):
Requires Jellyfin 10.11+. The plugin depends on the auth-provider APIs introduced in 10.11. If your server is on 10.10.x or older, the plugin will not appear in the Catalogue after adding the repository — Jellyfin silently filters out plugins whose
targetAbiis newer than the server. Check your version under Dashboard → About; upgrade to 10.11+ if needed.
https://raw.githubusercontent.com/ZL154/JellyfinSecurity/main/manifest.json
Jellyfin 12 moved to .NET 10, and a plugin compiled against 10.11 (.NET 9) will not load there. As of v2.6.0 the plugin ships two builds from one source and both are published in the same catalog entry: a .NET 9 package (targetAbi 10.11.0.0) for Jellyfin 10.11.x and a .NET 10 package (targetAbi 12.0.0.0) for Jellyfin 12.x. Jellyfin's catalog installs the build that matches your server's version, so there is nothing to choose. Installing from the catalog, or letting an existing install auto-update, does the right thing on both, and upgrading a 10.11 server to 12 later switches it to the .NET 10 build automatically.
If you install manually from the Releases page, pick the matching zip: ...v2.6.0.0-jf12.zip on Jellyfin 12, and the plain ...v2.6.0.0.zip on Jellyfin 10.11.
One thing looks different on 12: its default web layout has no side drawer, so the Two-Factor Auth entry lives in the avatar menu (below Profile) and in the user preferences list instead. Jellyfin 12 also disables the legacy authorization headers by default; the plugin's pages and endpoints already send Authorization: MediaBrowser Token (#174, #180), so nothing needs changing on that side.
# Windows
.\build.ps1 -Install
# Linux/macOS
chmod +x build.sh && ./build.sh --install
Download the release ZIP and extract the complete TwoFactorAuth/ directory into <jellyfin-data>/plugins/. Keep every bundled DLL, meta.json, logo.png, and the runtimes/ directory together. Do not cherry-pick only the main plugin DLL: OIDC, passkeys, GeoIP, recovery-code PDFs, and multi-architecture native support require the packaged dependencies.
Release builds validate the plugin version, Jellyfin metadata, required assemblies, case-sensitive file names, and artwork before the ZIP is produced.
Plugin directories by OS:
/config/plugins/TwoFactorAuth/~/.local/share/jellyfin/plugins/TwoFactorAuth/%LOCALAPPDATA%\jellyfin\plugins\TwoFactorAuth\Restart Jellyfin after copying.
https://your-jellyfin/TwoFactorAuth/Setup)From this point, every login from a new browser prompts for a code:
/web with username + password as usualPasskeys replace the 6-digit code with a biometric or hardware tap. They are phishing-resistant (the credential is bound to your exact domain) and require no typing.
Important — server config first. Passkeys require HTTPS AND the WebAuthn Relying Party ID + origin to match the URL the browser is on. In Dashboard → Plugins → Two-Factor Authentication → Settings → WebAuthn / passkeys:
jellyfin.example.com. No https://, no port, no path.https://jellyfin.example.com and https://jellyfin.example.com:8096. Add every URL users actually hit.If you skip this, browsers will refuse to register or use passkeys (Apple Safari is the strictest).
New registrations request ES256, the WebAuthn-required portable algorithm. This avoids the Ed25519/libsodium dependency that caused registration failures on some Linux, ARM, Windows, iOS, and password-manager combinations. Existing valid passkeys continue to work.
Common Android gotchas:
The official Jellyfin Android app and compatible web-shell clients can use the injected 2FA/SSO hand-off. If the buttons are missing after an upgrade, follow the mobile cache steps.
TVs and native clients that cannot display the browser challenge can use device pairing instead, as long as the admin has turned on BareDeviceIdBypassEnabled. It is off by default since v2.5.6, because a device ID is chosen by the client and is not a secret: anyone who has the password and sends a paired device's ID skips the second factor. There is no switch for it on the admin page; set <BareDeviceIdBypassEnabled>true</BareDeviceIdBypassEnabled> in /config/plugins/configurations/Jellyfin.Plugin.TwoFactorAuth.xml and restart Jellyfin. With it off, Trust below still records the device, but the retry gets the 2FA challenge again; use an app password instead.
BareDeviceIdBypassEnabled on, it now works and stays paired until you revoke it on the Setup page.This way a TV/console/media-box that can't type a TOTP code still gets its own credential you can revoke later.
Use app passwords: in Setup → App Passwords → Generate. You get a one-time shown random password. Use it in the app in place of your Jellyfin password. The plugin matches it via PBKDF2 hash and bypasses the 2FA prompt. Each app password can be revoked independently.
On the official Android app, sign in normally and complete the injected 2FA page; after verification the app returns to Jellyfin and the trusted session is recognised on its follow-up requests.
For clients without that browser-capable flow, use the device pairing process described in First-time setup, which needs BareDeviceIdBypassEnabled turned on (see there):
BareDeviceIdBypassEnabled on, it now works until you revoke the device.Alternative: generate an app password in Setup and use it in place of your real password. Useful for older apps or anything that can't tolerate the pairing-request delay, and it works with the default settings.
Use Jellyfin's standard API keys (Dashboard → API Keys) for the connection between these apps and Jellyfin. API key auth bypasses user authentication entirely, so 2FA doesn't apply to that connection.
Seerr and Jellyseerr also let people sign in with their Jellyfin username and password, and the API key does not cover that sign-in: the app forwards the credentials to Jellyfin as that user. An account with TOTP or a passkey gets the 2FA challenge, which these apps cannot complete, so the sign-in fails. Have each user create an app password (the Setup page asks for TOTP on the account first) and use it in the app instead of their Jellyfin password, or sign in with Quick Connect where the app offers it (Seerr 3.4 and later). Putting the app's address in the LAN bypass list also works, but then every sign-in that arrives through the app skips 2FA. Do not add the app's address to Trusted proxy CIDRs instead: with proxy support on, Seerr forwards the left-most X-Forwarded-For address it received, which the client can write itself, so the LAN bypass would follow whatever address the client claims.
The admin dashboard at Dashboard → Plugins → Two-Factor Authentication has 5 tabs:
Per-user 2FA status: TOTP on/off, trusted device count, recovery codes remaining, email address (for OTP), lockout status.
Every trusted device across all users with last-used time and expiry. Revoke any to force 2FA on that browser's next login.
Pending TV/native-client pairing requests. Approve or deny each request and show its QR code from the dashboard.
Paginated, filterable login attempt history. Tracks success, failures, lockouts, bypasses, and challenge issuances. Choose Newest first or Oldest first; the selected order remains active while navigating the dashboard.
RequireTwoFactorToDisable (re-prompts before a user can self-disable 2FA), StepUpLevel (which admin actions re-prompt for 2FA), AllowIndefiniteTrust (gates the user-side opt-in for never-expiring trust), DefaultLanguage (server-wide UI default; users can still override per-user)Lets users sign in with Google / Microsoft / Authelia / Authentik / Keycloak / PocketID / Cloudflare Access / etc. instead of (or alongside) a Jellyfin password. 2FA-less accounts work too — SSO replaces the password.
Matching logic when a user signs in via OIDC:
sub) → signs inImplicit email/username linking is refused for administrators, ambiguous matches, a different subject already linked to that provider, or one IdP subject already linked elsewhere. Admins link explicitly from their Setup page.
Linking from the Setup page (v2.5.13, #95): any signed-in user — including admins — can link a new provider from /TwoFactorAuth/Setup → Linked Sign-In Methods → "Link a new provider". It opens the IdP in a popup and links by subject to the current account, so admins can link without tripping the anti-takeover guard that blocks implicit admin linking during a normal sign-in.
1. Register a Google OAuth client
2. Add the provider in Jellyfin
email. Save.redirect_uri to register at the IdP — it's https://YOUR-JELLYFIN-HOSTNAME/TwoFactorAuth/Oidc/Callback/<slug>, where <slug> is derived from the Display name you chose (e.g. Google → google, Login with Google → login-with-google). Go back to Google Cloud Console → Credentials → your OAuth client → add this exact URL to Authorised redirect URIs and save. If the slug doesn't match what's registered, the IdP returns redirect_uri_mismatch and sign-in fails (issue #28).3. Make sure each Jellyfin user has their Gmail configured
/TwoFactorAuth/Setup), or4. Done. Sign out and the login page now shows a "Sign in with Google" button. Click → Google consent → bridge page → signed in.
| Preset | Discovery auto-filled | Notes |
|---|---|---|
| ✅ | Username claim: email | |
| Microsoft / Entra | ✅ | Replace common in discovery URL with tenant ID for single-tenant apps |
| Apple | ✅ | Returns email only on first sign-in; no email_verified claim |
| Authelia | — | Paste https://authelia.domain/.well-known/openid-configuration |
| Authentik | — | Copy discovery URL from provider details in Authentik admin; see expired or incompatible signing certificates |
| Keycloak | — | https://keycloak.domain/realms/<realm>/.well-known/openid-configuration |
| PocketID | — | https://pocketid.domain/.well-known/openid-configuration |
| Cloudflare Access | — | SaaS → OIDC app → discovery URL ends /cdn-cgi/access/sso/oidc/<app-id>/.well-known/openid-configuration |
| GitHub | ❌ | OAuth2 only, not OIDC — not yet supported |
| Discord | ❌ | OAuth2 only, not OIDC — not yet supported |
groups / roles claim contains at least one of these. (v2.5.17) Keycloak nests roles under realm_access / resource_access rather than a flat claim — the plugin now reads those too (here, and for Admin groups + role→library mapping); request the built-in roles scope on the provider and enable "Add to ID token" on the realm-roles mapper.email_verified: true) matches a Jellyfin user's configured email is linked to that account instead of creating a duplicate.groups claim matches an entry here is granted Jellyfin admin on sign-in. Grant-only (never auto-revokes); every elevation logged at WARN. Only enable for an IdP you fully control — a compromised IdP that controls the groups claim could elevate any account.amr claim indicates MFA (mfa, hwk, otp, sca)end_session_endpoint so the IdP session ends too, instead of leaving it live for the next visitor to that browser. Off by default. Requires the provider to publish end_session_endpoint in its discovery document; if it doesn't, sign-out silently stays local. The plugin sends client_id and not id_token_hint, since it never retains id_tokens. Keycloak (>= 18) and Authentik accept that form, and an OP that insists on id_token_hint will show its own generic sign-out page instead. Only the browser bridge participates; native-app sign-in keeps stock local sign-out.https:// URL sent as post_logout_redirect_uri so the browser comes back to Jellyfin instead of stopping on the IdP's logged-out page. Leave empty (the default) to stop there, which is what most people want. Register the exact URL at the provider first, because most OPs reject the entire sign-out request when it carries an unregistered redirect. {server}/TwoFactorAuth/Oidc/LoggedOut is provided for this.prompt=login) (v2.5.19, #119, opt-in) — compatibility switch for IdPs such as Authentik that reject prompt=login. Leave it off unless needed because forced re-authentication protects account-link, step-up, and onboarding validation flows from silently accepting an existing IdP session.169.254.1.2/32. /0 is rejected; each listed CIDR bypasses safety checks for matching addresses, so only list addresses you own. See OIDC private / VPN / LAN endpoints.Auto-bans source IPs that hammer the login endpoint. Fail2Ban-style, entirely in-process — no external service needed.
Configure: Jellyfin Security → Settings → "Brute-Force Protection":
Always exempt: LAN-bypass CIDRs, trusted-proxy CIDRs, anything in the exempt list.
Manage bans: Jellyfin Security → IP Bans tab lists all active bans with expiry. Click "Unban" to clear. You can also manually ban an IP here (e.g. "someone who's been guessing").
Bans persist across restarts via <config>/plugins/configurations/TwoFactorAuth/ip-bans.json.
Flags sign-ins where the geographic distance vs. elapsed time exceeds commercial-jet cruise speed. London → Tokyo in 30 minutes ≈ Mach 20: notification fires.
Requires: MaxMind GeoLite2-City.mmdb. Free account, download the City DB, drop it in /config/geoip/, paste the path in Settings → Impossible-Travel Detection. The path must be the one the Jellyfin process sees (inside the container, for Docker) and readable by the user Jellyfin runs as; the Diagnostics tab tells you which of those is not the case.
Signal path: Triggers the same Notification channels the plugin already uses (ntfy, Gotify, webhook, admin emails). Includes distance, duration, inferred speed, and country hop in the message.
Off by default; enable in Settings once the city DB is in place.
Pin a user account to specific CIDRs. Empty = no restriction (default). Useful for admin accounts where lateral exposure hurts most.
Configure (user self-service): Setup page → IP Allowlist card → one CIDR per line → Save.
Configure (admin, per user): PUT /TwoFactorAuth/IpAllowlist/User/{userId} (UI not wired in yet; edit the user JSON or use the API).
⚠ Self-lockout risk: if you typo a CIDR, you can't sign in. Recover by editing /config/plugins/configurations/TwoFactorAuth/users/<your-guid>.json and clearing IpAllowlistCidrs.
Re-prompts the admin for a fresh 2FA challenge before sensitive operations. Defends against a logged-in session being hijacked or left unattended on a workstation.
Configure: Jellyfin Security → Settings → Hardening → Step-up level:
| Level | What re-prompts |
|---|---|
Off | Nothing. (Default — opt in deliberately.) |
Destructive | Deleting users, wiping 2FA state, rebuilding the audit chain, removing OIDC providers. |
AllConfigChanges | All of Destructive, plus toggling settings, editing SMTP / push / brute-force / impossible-travel config. |
Everything | All of AllConfigChanges, plus viewing audit log, listing IP bans, exporting config. (Strongest — least convenient.) |
How the flow looks:
Related setting: RequireTwoFactorToDisable — when on, users can't disable their own 2FA without entering a fresh code first. Stops a stolen session cookie from being used to switch 2FA off.
Back up or migrate plugin configuration (settings, OIDC providers, trusted CIDRs, brute-force config, etc.) without leaking secrets.
Export (admin):
.json.enc envelope. Treat it like a password — its strength is the passphrase's.Import (admin):
.json.enc file → enter the same passphrase → review the preview of what will change → confirm.Crypto envelope (so you can audit it):
{ "v": 1, "salt": "...", "nonce": "...", "ct": "...", "tag": "..." } — future versions can change parameters without breaking decryption of older exports.⚠ No back door: a lost passphrase means the export is unrecoverable. The plugin author cannot decrypt your file. Store the passphrase in your password manager separately from the export file.
A 12-factor security score (raw 130 points, normalized to 100) and a live auth-activity chart on the admin dashboard.
In v2.5.20, posture checks initialise independently on Jellyfin 10.11.11. A failed or unavailable diagnostic is reported for that factor without leaving the score on Computing..., and dashboard tab navigation remains usable.
| Factor | Points | What it checks |
|---|---|---|
| Coverage | 30 | % of live users enrolled in 2FA (deleted accounts are no longer counted, v2.5.18) |
| Admin coverage | 20 | All admins specifically have 2FA on |
| Enforcement | 15 | RequireForAll is on |
| Audit chain | 10 | Hash chain is intact (no breakage) |
| IP ban | 8 | Brute-force banning enabled with sane threshold |
| Impossible travel | 7 | Functional — requires GeoIpCityDbPath set to a valid MaxMind file |
| HIBP | 5 | Have-I-Been-Pwned password check enabled |
| Clean 7-day audit | 5 | No failed admin sign-ins in the last 7 days |
| Require-to-disable | 8 | RequireTwoFactorToDisable is on |
| Step-up | 7 | StepUpLevel is Destructive or stronger |
| Webhook | 5 | Push notifications (ntfy / Gotify / webhook) configured |
| Recovery codes | 5 | At least one user has generated recovery codes |
Admin dashboard → Overview tab shows a stacked-area chart of successful / failed / blocked sign-ins.
Every user-visible string in the setup, login, challenge, OIDC onboarding, admin pages, and injected desktop/mobile sidebar is translatable. Ships with 8 first-class languages at full key parity (847 keys each).
| Language | Locale | Display name in picker |
|---|---|---|
| English | en | English |
| Deutsch | de | Deutsch |
| Español | es | Español |
| Français | fr | Français |
| Italiano | it | Italiano |
| 日本語 | ja | 日本語 |
| Português | pt | Português |
| 中文 | zh | 中文 |
How the active language is chosen (first match wins):
?lang=de.DefaultLanguage.The injected dashboard entry updates its label live when Jellyfin's language changes and is available in both desktop and mobile navigation.
Native-name picker — the picker shows each language in its own script ("Deutsch", "日本語", "中文") rather than locale codes, so a user who only reads Japanese can find their language without reading English.
Implementation notes (for translators / contributors):
src/Jellyfin.Plugin.TwoFactorAuth/Pages/translations/<lang>.json and are served via /TwoFactorAuth/translations/{lang} with strong caching.tfa-i18n.js helper exposes window.tfaI18n.tr(key, fallback), loadTranslations(lang), applyTranslations(root), renderLanguagePicker(container), getEffectiveLanguage(), and a ready promise so dynamic JS-rendered content doesn't render in English before the bundle loads./TwoFactorAuth/public-config exposes the server-wide default language to anonymous pages (login / challenge) without leaking other config.Want to add a language? Copy translations/en.json → translate → drop in translations/<your-locale>.json. The picker auto-discovers new files. Pull requests welcome.
Lets a user mark a specific trusted browser or paired device as "trusted forever" instead of "trusted for 30 days." Useful for a personal phone or home TV where the user would rather have one less prompt and accept the residual risk if the device is lost.
Admin gate (default off): Jellyfin Security → Settings → Hardening → AllowIndefiniteTrust. When off, the user-side opt-in toggle is hidden entirely — no way to enable per-device. When on, users see an Indefinite trust toggle on each of their trusted browsers / paired devices.
User opt-in (per device):
Revoke / undo: same toggle off. Or revoke the device entirely from Setup → Trusted Devices.
⚠ Tradeoff — an indefinite-trust device is your weakest link. If someone steals the laptop, that browser is signed in until you revoke it. Don't enable on shared / borrowed machines, and revoke immediately on device loss. The admin gate exists so org admins can keep this off entirely if their threat model doesn't tolerate the tradeoff.
Closes the stolen-session takeover path. Before v2.5.6, an attacker who hijacked an authenticated browser cookie could silently enroll their own authenticator (add a passkey, generate a new TOTP secret, regenerate recovery codes) without ever proving they were the legitimate user — the original 2FA only gated login, not factor changes. v2.5.6 closes that.
Setting: Jellyfin Security → Settings → Hardening → Hardened security for users (factor changes). Tri-state:
Covered mutations — adding/replacing TOTP, regenerating recovery codes, creating an app password, adding/removing a passkey, enabling/disabling email OTP. All gated.
Proof of factor — the step-up prompt accepts any of:
Step-up tokens are single-use, 60-second TTL, and bound to the requesting user — they can't be replayed or reused for a second mutation.
Lets a user satisfy the hardened self-service step-up by re-authenticating to a linked OIDC provider, instead of needing a TOTP / passkey / recovery code. Useful for users whose only configured factor is OIDC (common in OIDC-only deployments — see "Hide built-in login buttons" below).
How it works:
GET /TwoFactorAuth/Oidc/MyLinks).prompt=login so the IdP must actually re-authenticate the user — silent SSO confirmation is rejected./TwoFactorAuth/Oidc/Callback/{providerId} endpoint. The state token marks this as a step-up flow.sub matches the user's stored SsoLink for that provider. Both must match. Signing into a different IdP account doesn't grant step-up.postMessages it back to the opener (same-origin only), and closes the popup.Security guards:
prompt=login defeats a hijacked-session attacker who clicks "Sign in with X" hoping for a silent confirmation.SsoLink defeats a hijacked-session attacker who happens to have their own account at the same IdP.postMessage target is restricted to window.location.origin, never '*'.The "Verify with X" buttons only appear in the step-up modal when the user has at least one OIDC link; they don't add UI for users who don't use OIDC.
For OIDC-only deployments where every user signs in through your IdP and the plugin's injected sign-in shortcuts add noise. Two independent admin toggles in Settings → Hardening:
inject.js adds to Jellyfin's main login page.Each is independent — pick any combination. Configured OIDC provider buttons stay visible regardless of these flags.
Login-link placement & Forgot-password (v2.5.16, #79, ZEROX7): an opt-in Settings → Hardening → "Show the SSO / 2FA / passkey links below the Use Quick Connect button" toggle (default off) moves the injected links beneath Quick Connect instead of directly under Sign In. Separately, the native "Forgot password" link is now hidden automatically when there's no visible password field (e.g. OIDC-only login), since there'd be nothing to recover.
⚠ The /TwoFactorAuth/Login page still works directly even when both toggles are on. Admins/fallback users can always reach it by URL, so you don't lock yourself out of the plugin's login flow if your IdP becomes unreachable.
Lets you point the plugin at an IdP that lives on a private network (Tailscale, Wireguard, LAN-only Authentik / Authelia / Pocket ID, etc.). Without this toggle, v2.5.5's SSRF guard rejects any OIDC discovery URL that resolves to an RFC1918 / loopback / link-local address, or that uses plain http.
Setting: per-provider, in the OIDC provider edit form → Allow private / VPN / LAN endpoints (marked Advanced, default off).
Granularity: per-provider. A public Google + a private Authentik can coexist — Google keeps the strict SSRF guard, Authentik gets the bypass. The toggle scopes to ONE provider's discovery / token / userinfo / jwks fetches; other providers are unaffected.
⚠ Trade-off — enabling this for a provider whose discovery URL gets tampered with would let an attacker pivot the plugin into your internal services (e.g. AWS IMDS at 169.254.169.254, internal admin APIs, the Docker daemon socket via host networking). Only enable for IdPs you intentionally host on private networks where the network boundary IS the security boundary.
Finer-grained alternative (v2.5.16, #103, andrewdunndev) — even with "Allow private networks" on, the guard still blocks link-local addresses (169.254.0.0/16, the IMDS range), which catches the rootless-Podman host-gateway 169.254.1.2 (host.containers.internal). Rather than open the whole private bypass, use the per-provider "Additional allowed CIDRs" field to allowlist exactly that one address (169.254.1.2/32). It's surgical (/0 and out-of-range prefixes are rejected) and each listed CIDR only bypasses the check for matching addresses.
The OIDC spec doesn't let admins mix-and-match per-endpoint — the IdP's discovery document dictates which token / userinfo / jwks URLs the plugin fetches, and they all live in the same network as discovery. So per-provider is the natural granularity.
Closes the "session permanently 403'd after restart" issue (#52). Before v2.5.7, the plugin tracked which access tokens had completed 2FA in an in-memory dictionary. After a docker compose down/up (or any process restart), that dictionary was empty — but the user's Jellyfin auth token was still valid in Jellyfin's DB. The failsafe BlockToken then triggered on every SessionStarted reconnect, and RequestBlockerMiddleware 403'd every API call. The user couldn't even reach /Users/Me/Logout — they had to wipe local storage.
Fix: SHA-256 hashes of verified tokens persist to {plugin-data}/verified_tokens.json. On every restart, the hashes are loaded back into the in-memory set, so already-verified sessions stay verified.
What's stored:
What's NOT stored — never the plaintext token, never user ids, never device ids. Just hash + expiry.
Operational signal — after the first restart following a successful login, the log emits [2FA] Loaded N verified-token hashes from /config/plugins/configurations/TwoFactorAuth/verified_tokens.json. That confirms persistence is active.
Email OTP requires SMTP credentials. Common providers:
SMTP Host: smtp.gmail.com
SMTP Port: 587
Use SSL/TLS: ✓
SMTP Username: your-email@gmail.com
SMTP Password: <generate at https://myaccount.google.com/apppasswords>
From Address: your-email@gmail.com
From Name: Jellyfin 2FA
SMTP Host: mail.example.com
SMTP Port: 587 (STARTTLS) or 465 (implicit TLS)
Use SSL/TLS: ✓
Email OTP needs the user's email address. In Admin → Users, edit each user's email field. The plugin doesn't auto-pull from Jellyfin user metadata (Jellyfin's User entity exposes email inconsistently across versions).
Sign in via /TwoFactorAuth/Login. In the code field, enter one of your recovery codes (format: XXXXX-XXXXX). Click "Use a recovery code instead" if your authenticator app field is showing. (v2.5.18) If you're already signed in and hit the "Verify your identity" screen, it now shows a Recovery tab too (whenever you have unused recovery codes), so you can fall back to a recovery code mid-session.
SSH into the Jellyfin server and edit the user data file:
# Path
/config/plugins/configurations/TwoFactorAuth/users/{userId}.json
# Set:
"TotpEnabled": false,
"TotpVerified": false,
"EncryptedTotpSecret": null,
"RecoveryCodes": [],
"TrustedDevices": []
Restart Jellyfin. The user can now log in normally and re-enroll.
InvalidAuthProvider)Signing in with a passkey, creating an app password, or signing in through SSO moves that account onto the plugin's own sign-in provider. If Jellyfin then stops loading the plugin (for example a build made for another Jellyfin version, or missing files), it has no provider for those accounts and refuses their password, the correct one included, administrators too. The Jellyfin log shows:
User alice was found with invalid/missing Authentication Provider Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider. Assigning user to InvalidAuthProvider until this is corrected
Authentication request for alice has been denied (IP: ...).
Uninstalling or disabling the plugin from Dashboard → Plugins no longer causes this: the plugin first hands those accounts back to Jellyfin's own provider, and takes them back the next time it starts. The steps below are for a plugin that does not load at all, which never gets that chance.
Get the plugin loading again, if you can. Nothing is lost: the accounts sign in as before as soon as it loads. If Dashboard → Plugins lists it as Disabled, enable it and restart Jellyfin. With no administrator able to sign in, set "status": "Active" in the plugin's meta.json (in its folder under /config/plugins/) and restart Jellyfin.
If an administrator can still sign in, open Dashboard → Users, pick the account and press Save without changing anything. With the plugin not loaded, Jellyfin offers only its own provider, so saving the profile moves the account onto it. If another sign-in plugin (LDAP, for example) is installed, the page shows Authentication Provider: pick the one the account should use.
If no administrator can sign in, move the accounts in Jellyfin's database. Stop Jellyfin, run the commands below (with the sqlite3 tool) and start Jellyfin again:
# In the official Docker image the database is data/jellyfin.db inside the folder mounted at /config.
# Elsewhere, find it with: find / -name jellyfin.db 2>/dev/null
cp /path/to/jellyfin.db* /path/to/backup/
# The accounts on the plugin's provider
sqlite3 /path/to/jellyfin.db "SELECT Username FROM Users WHERE AuthenticationProviderId = 'Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider';"
# Move them to Jellyfin's own provider; prints how many were moved
sqlite3 /path/to/jellyfin.db "UPDATE Users SET AuthenticationProviderId = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider' WHERE AuthenticationProviderId = 'Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider'; SELECT changes();"
The plugin does not undo steps 2 and 3. Once it runs again, an account moved this way still gets its second factor, but its app passwords are refused until it creates a new one; after that, its older app passwords work again too.
To always have a way in, keep one administrator account that never signs in with a passkey, never creates an app password and never signs in through SSO. That account stays on Jellyfin's own provider, so it can sign in and run step 2 for the others even when the plugin does not load.
The Android app and mobile browsers can retain Jellyfin's web shell from before the plugin was installed or upgraded. The plugin now prevents its patched index.html from being cached, but an older shell already stored on a device may still need one manual refresh:
/jellyfin, make sure the device opens that full URL, for example https://media.example.com/jellyfin.<your Jellyfin URL>/TwoFactorAuth/inject in the same browser. It should return JavaScript, not a 404 or a proxy error./web/index.html, /web/, or /TwoFactorAuth/*.After one successful refresh, the login buttons and the Two-Factor Auth entry (sidebar on 10.11, avatar menu on Jellyfin 12) should appear normally. Clearing the full app storage is not normally required and will sign the device out.
From v2.5.21 this message is much rarer, and when it does appear it now tells you what to fix. Instead of one generic string, the sign-in page reports the actual cause — an expired IdP certificate, a signing-key mismatch, a Client ID mismatch, clock drift, or an unsupported signing algorithm. Follow whatever it says; the full technical detail is in the Jellyfin server log.
Two changes in v2.5.21 are worth knowing about:
Expired signing certificates no longer block sign-in. Authentik generates self-signed signing certificates that expire after one year and does not rotate them automatically. Earlier versions rejected the token once that certificate lapsed (IDX10249), even though the signature itself was still valid — an outage with no security benefit, since the plugin fetches the JWKS over TLS from the issuer's own discovery endpoint and that, not the certificate's validity window, is the trust anchor. The signature is still fully verified on every sign-in; only the certificate's expiry date is no longer treated as fatal. You should still renew it (System → Certificates in Authentik), but a lapsed certificate will not lock your users out.
If the error mentions the signing algorithm, the provider is signing with HMAC (HS256) rather than a key pair. In Authentik that means the provider has no Signing Key selected. Pick an RSA certificate there. The plugin accepts the standard asymmetric OIDC algorithms (RS256/384/512, ES256/384/512, PS256/384/512) and deliberately refuses HMAC and none — that allowlist is what closes the RS256→HS256 algorithm-confusion attack, so it is not configurable.
If the error names a RSA-OAEP / RSA-OAEP-256 (or another RSA-*, ECDH-ES*, *KW, or dir) algorithm, that is a key-encryption algorithm, not a signing one: your IdP is returning an encrypted ID token (JWE), and the plugin, like most OIDC clients, verifies a signed token (JWS) against your IdP's published keys rather than decrypting one. In Authentik this is the provider's Encryption Key under Advanced protocol settings — set it to blank / --------- so Authentik returns the plain signed JWT the plugin can verify. Keep the Signing Key set (an RS256 certificate is fine); it is only the Encryption Key that causes this. Encrypting the ID token buys little here anyway, since the exchange is already over TLS and the token is validated server-side. Encrypted (JWE) ID tokens are not supported today. (Reported in discussion #188.)
If you changed or rotated the signing key and sign-in still fails, restart Jellyfin so the JWKS cache picks up the new key. See Authentik's certificate management and OAuth2/OIDC provider documentation.
Permissions-Policy and synchronous XHRIf you deployed the Permissions-Policy header from Jellyfin's official nginx example, Chromium-based browsers log:
Error with Permissions-Policy header: Unrecognized feature: 'ambient-light-sensor'.
Error with Permissions-Policy header: Unrecognized feature: 'battery'.
Error with Permissions-Policy header: Unrecognized feature: 'document-domain'.
Error with Permissions-Policy header: Unrecognized feature: 'interest-cohort'.
[Violation] Permissions policy violation: Synchronous requests are disabled by permissions policy.
The first four are harmless: those features were removed from the spec, so Chromium warns and ignores them. Dropping them from the header silences the noise:
add_header Permissions-Policy "accelerometer=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always;
The sync-xhr violation does not come from this plugin. Jellyfin Security issues no synchronous XMLHttpRequest anywhere — every request it makes, on every page, uses fetch() — so you can keep the strict sync-xhr=() baseline. The violation is raised by another plugin's bundled jQuery calling $.ajax({ async: false }), and the culprit is named on the line above the inject.js frame in the stack trace.
Jellyfin Security patched XMLHttpRequest.prototype.send globally, which put inject.js in the stack of those third-party calls and made it look responsible. As of v2.5.21 the plugin passes synchronous requests straight through untouched, so the stack trace now points at the real caller. If a plugin genuinely needs synchronous XHR, either report it upstream or relax the header to sync-xhr=(self) for that deployment.
Disable the plugin without uninstalling:
# Edit
/config/plugins/configurations/Jellyfin.Plugin.TwoFactorAuth.xml
# Set
<Enabled>false</Enabled>
Restart Jellyfin. All 2FA enforcement turns off; users can log in normally.
Enabled is read by the plugin itself, so it only helps while Jellyfin still loads the plugin. If the plugin does not load at all and accounts are refused, see Sign-in refused after the plugin stopped loading.
Fixed at the source in v2.4.12 — on a current build you should not hit this. Requests blocked pending 2FA now return 403, not 401, and SWAG's default
nginx-unauthorizedjail only counts 401s, so a normal 2FA login no longer trips a ban (issue #36). The injected script also short-circuits the follow-up API calls so the browser stops hammering the server while the challenge is open. The tuning below is kept only for older builds, or if you run a custom jail that also bans on 403.
If you run Jellyfin behind SWAG (linuxserver.io's all-in-one nginx + fail2ban + Let's Encrypt container) or any other stack with a fail2ban jail watching for HTTP 401s, on a build older than v2.4.12 you may have seen this symptom:
ERR_CONNECTION_REFUSEDWhy this happens. When 2FA enforcement is on and a user logs in, the plugin's RequestBlockerMiddleware 401s every post-login API call from the browser (/Sessions/Capabilities/Full, /DisplayPreferences/usersettings, /socket, /System/Endpoint, etc.) until the user completes 2FA — that's roughly 15 401s in a few seconds per legitimate login.
SWAG's default nginx-unauthorized fail2ban jail watches the nginx access log for any 401 response code (regardless of which backend produced it) and bans the source IP after 5 in 10 minutes. A single 2FA login trips it. The ~15-minute recovery cycle matches the jail's default bantime = 600.
The "everything else breaks" symptom depends on what IP fail2ban actually bans:
Fix. Drop this into /config/fail2ban/jail.d/jellyfin.local:
[nginx-unauthorized]
maxretry = 30
findtime = 600
That changes "ban after 5 401s in 10 min" → "ban after 30 401s in 10 min." A normal 2FA login generates ~15 401s, so 30 gives ~2× headroom while still catching real brute-force (hundreds of 401s per minute).
Scale by user count — fail2ban counts per source IP, and if you're behind Cloudflare or a similar CDN, ALL your users share the same source IP from fail2ban's view. Simultaneous logins compound:
| Users on the server | Recommended maxretry |
|---|---|
| 1 (solo) | 30 |
| 2–3 (small household) | 50 |
| 4–6 (family) | 100 |
| 10+ (community / extended) | 150 or enabled = false |
Restart SWAG (docker restart swag or your equivalent) after the change.
Alternative — disable the jail entirely. If you'd rather not patch fail2ban:
[nginx-unauthorized]
enabled = false
You lose protection against generic 401-burst attacks on all apps behind SWAG (not just Jellyfin), but the other default SWAG jails (nginx-http-auth, nginx-badbots, nginx-botsearch, nginx-deny) still cover the common brute-force vectors.
Why this isn't strictly a plugin bug. The plugin behaves correctly per HTTP/OAuth (401 on unverified tokens). SWAG's fail2ban behaves correctly per brute-force-protection norms. The collision sits in the gap between the two — fail2ban can't tell a legitimate 2FA enforcement burst from an attack just by reading status codes in the access log. This was resolved at the source in v2.4.12 (issue #36): the blocked-request response is now 403, which the default nginx-unauthorized jail does not count, so a normal 2FA login no longer trips it. The jail-threshold tuning above is only needed on older builds or a custom jail that also bans on 403.
The plugin uses 5 ASP.NET Core middleware components plus an ISessionManager.SessionStarted event handler:
IndexHtmlInjectionMiddleware — injects the Base URL-aware login/dashboard script into Jellyfin's index.html, prevents stale web-shell caching, and restores controls after single-page navigationTrustCookieMiddleware — checks the __2fa_trust cookie on auth requests; if valid, marks the user as pre-verified for the upcoming sessionTwoFactorEnforcementMiddleware — inspects responses from auth endpoints (catches the auth response shape regardless of which Jellyfin route was used)RequestBlockerMiddleware — blocks API requests from authenticated users who haven't completed 2FA yet (returns 401)AuthenticationEventHandler (hosted service) — subscribes to SessionStarted; if a session for a 2FA-enabled user starts without verification, the user is added to the blocker's blocklist. Repeated native-client events for the same logical user/device are deduplicated before notifications are sent.Persistent state:
users/{userId}.json — per-user TOTP secret (AES-GCM encrypted), recovery codes (per-code-salted PBKDF2-HMAC-SHA256, 600k iterations), trusted devices, lockout statesecret.key — 32-byte AES-GCM key for TOTP secret encryptioncookie.key — 32-byte HMAC-SHA256 key for trust cookie signingaudit.json — login attempt logAll file writes use atomic write-then-rename so crashes mid-write don't corrupt user state.
GET /TwoFactorAuth/Login — login page (HTML)
GET /TwoFactorAuth/Setup — enrollment page (HTML)
GET /TwoFactorAuth/Challenge?token=... — challenge page (HTML)
GET /TwoFactorAuth/inject — cache-resistant login/dashboard injection script
POST /TwoFactorAuth/Authenticate — username + password + code login
POST /TwoFactorAuth/Verify — verify code against challenge token
POST /TwoFactorAuth/Email/Send — request email OTP for current challenge
POST /TwoFactorAuth/Setup/Totp — generate TOTP secret + QR (auth)
POST /TwoFactorAuth/Setup/Totp/Confirm — confirm TOTP enrollment (auth)
POST /TwoFactorAuth/Setup/Disable — disable 2FA for self (auth)
POST /TwoFactorAuth/RecoveryCodes/Generate — generate recovery codes (auth)
GET /TwoFactorAuth/RecoveryCodes/Status — count remaining (auth)
GET /TwoFactorAuth/Devices — own trusted devices (auth)
DELETE /TwoFactorAuth/Devices/{id} — revoke own trusted device (auth)
POST /TwoFactorAuth/Devices/Register — pre-register device ID (auth)
RequiresElevation)GET /TwoFactorAuth/Users — all users with 2FA status
POST /TwoFactorAuth/Users/{id}/Toggle — enable/disable 2FA for user
GET /TwoFactorAuth/AllTrustedDevices — devices across all users
DELETE /TwoFactorAuth/Users/{userId}/Devices/{deviceId} — admin revoke
GET /TwoFactorAuth/AuditLog — login history
GET /TwoFactorAuth/Pairings — pending TV pairings
POST /TwoFactorAuth/Pairings/{code}/Approve — approve pairing
POST /TwoFactorAuth/Pairings/{code}/Deny — deny pairing
GET /TwoFactorAuth/ApiKeys — list managed API keys
POST /TwoFactorAuth/ApiKeys — generate new API key
DELETE /TwoFactorAuth/ApiKeys/{id} — delete API key
POST /TwoFactorAuth/Sessions/{id}/Revoke — revoke an active session
| Threat | Mitigation |
|---|---|
| Stolen password (no 2FA bypass) | All sessions blocked until 2FA completed; correct password alone gives 401 on every API call |
| TOTP brute force on the 6-digit code space | Per-IP rate limit (10/min on verify, 10/min on auth), per-challenge attempt limit (5), per-user lockout (5 failures → 15min) |
| Stolen recovery code | Marked used immediately on validation regardless of password outcome — can't be retried |
| Stolen trust cookie | HMAC-SHA256 signed with persistent server-side key; HttpOnly, Secure, SameSite=Strict; tied to a server-side trust record (revocable) |
| Account enumeration | Identical "invalid credentials" message whether password is wrong, user doesn't exist, or 2FA code is wrong |
| Disk corruption mid-write | Atomic write-then-rename for all user state files |
| TOTP secret theft from disk | AES-GCM encrypted with persistent 32-byte key |
| Replay attacks on TOTP | Used time-steps tracked per user |
| Timing attacks | CryptographicOperations.FixedTimeEquals on all secret comparisons |
| OIDC account-link confusion | Stable-subject links take precedence; implicit email/username matching is non-admin only, opt-in where applicable, and refuses ambiguous or conflicting identities |
| Stale OIDC onboarding page/session theft | Live IdP revalidation plus a short-lived single-use proof is required before setting the password; cancel revokes the temporary server session |
| Expired or unapproved OIDC signing key | Standard issuer/audience/nonce/certificate validation plus an explicit RSA/ECDSA/RSA-PSS algorithm allowlist |
| Service integrations breaking | Standard Jellyfin API keys bypass user auth — Sonarr/Radarr unaffected |
| Authelia/Authentik breaking native apps | Native plugin, no proxy dependency |
/TwoFactorAuth/Login).redirect_uri, pairing QRs, and password-reset email link use the server's real public address instead of the request host (a LAN address for a TV that reaches Jellyfin directly). Empty by default, falling back to Jellyfin's own published server URI; the value comes only from admin config, never a request header (#219).RPID/RPName rename) and routine test tooling; QuestPDF and IdentityModel pins deliberately held (#229).TwoFactorRequired response and continues to the TOTP prompt instead of stopping on "Sign-in could not be completed. Error: HTTP 401". Applies to both the browser callback and the in-app webview (#205, fixes #204).libsodium.so core dumps (seen on the linuxserver.io image on Jellyfin 12): the recovery-code PDF path no longer eagerly loads libsodium, and the plugin repairs the architecture-correct native library at load; libsodium is loaded only when a passkey is actually used (#212, fixes #203).targetAbi 10.11.0.0) for Jellyfin 10.11.x and a .NET 10 package (targetAbi 12.0.0.0) for Jellyfin 12.x, both published in the one manifest.json under the same GUID so Jellyfin's catalog routes each host to its build (#196, #172).POST /Users/{userId}/Authenticate endpoint was not gated (and also skipped empty-password blocking and per-account lockout), and the SSO waiver was a string-prefix test rather than a live token lookup. Both are fixed; valid credentials were always still required (reported privately by @camarigor).Authorization: MediaBrowser Token header alongside the legacy X-Emby-Token, required once legacy authorization is disabled on Jellyfin 12 (#174, #180).GET /TwoFactorAuth/Users/{id}/Summary and pointed the admin Users details panel at it, so expanding a row no longer hits the step-up-gated export and fails with "Failed to load details"; the per-user Export button now prompts for step-up instead of failing silently (#156).sync-xhr Permissions-Policy violation is attributed to its real caller, and documented an obsolete-feature-free Permissions-Policy header (#149).prompt=login for IdPs such as Authentik that reject forced re-authentication (#119)./.well-known/openid-configuration (#120).TwoFactorEnforcementMiddleware and TwoFactorAuthProvider now add recovery to the challenge's method list whenever the user has unused recovery codes, not only during an emergency lockout (ForceRecoveryOnNextLogin). The Recovery tab in the challenge UI is therefore reachable in the normal verify flow, matching the login portal (which always offered "Use a recovery code instead").UserTwoFactorData record, including orphaned records left behind by deleted accounts, which capped the score; it now counts live Jellyfin users via the ABI-safe EnumerateUsers() shim.data-i18n-html attribute (innerHTML) so the SSO redirect-URI hint renders its <code> snippet instead of literal markup, in all 8 languages. 266/266 tests pass. In-place upgrade. (Shipped 2026-07-03.){server}/{topic} (topic as a path segment, not a header).email_verified surfaced as the C# string "True", so the exact == "true" check read verified emails as unverified and skipped account matching; now compared case-insensitively.realm_access.roles / resource_access.{client}.roles rather than a flat claim; these are now read from the id_token and /userinfo to drive "Allowed groups", "Admin groups", and role→library mapping. 266/266 tests pass. In-place upgrade. (Shipped 2026-07-03.)DefaultAuthenticationProvider, where the app-password check (which lives in the plugin's provider) never runs, so the submitted app password was validated against the real password and rejected. Creating an app password now reassigns the user's AuthenticationProviderId to the plugin provider (same as the passkey / OIDC paths). Re-create any existing app password after updating./0 and out-of-range prefixes rejected.sub_filter on the head-closing tag (common for rebranding "Jellyfin" in the browser tab) was also matching that tag where it appeared inside one of the Setup page's own inline-JS strings (the recovery-codes print template), injecting a script-closing tag mid-script — which dumped the rest of the page as raw text and left "Linked Sign-In Methods" stuck on "Loading…". The print template now builds every structural tag in split pieces, so no closing head/title/style/script tag appears as a literal substring for a proxy sub_filter to latch onto. 266/266 tests pass. In-place upgrade. (Shipped 2026-06-23.)UpdatePolicyAsync (the same path the dashboard uses).Oidc/LinkBegin → popup → link by sub) instead of the normal sign-in, which routes through the resolver that deliberately refuses implicit admin links. Refuses if the identity is already linked to a different Jellyfin user.AdminGroups is now consumed, behind a new opt-in "Elevate matching users to administrator" toggle (default off). Grant-only (never auto-revokes); every elevation logged at WARN. Off-by-default, so a default install is unchanged.AuthenticateByName fails (commonly an auth proxy intercepting it), the bridge page shows the real error + an auth-proxy hint + a manual link instead of an endless login loop.StepUpLevel is set to AllConfigChanges or above, clicking Save in the admin UI used to silently fail with no UI prompt because the main config save call went through Jellyfin's built-in ApiClient helper, which doesn't know about the plugin's stepUpRequired response. Re-wired through the existing step-up-aware fetch wrapper so the same TOTP modal that gates every other admin action now also gates plugin-config saves.StepUpLevel dropdown persists across saves — enum-serialization mismatch was making the dropdown go blank after every save, and silently posting 0 (Off) which reset the level on the server. The dropdown's <option value=> strings now match Jellyfin's JsonStringEnumConverter wire format.10.0.0.0/8) into Trusted Proxy CIDRs caused LAN bypass to silently refuse for every LAN client (the SEC-H3 guard from v2.4.12 can't distinguish "stale-XFF proxy" from "direct LAN client in a broad range"). Added a help block under the admin field spelling out the trap, and promoted the SEC-H3 refusal log to Information level on first hit per peer IP so admins see the actionable diagnostic in their logs without filtering for Debug.alert + console.error instead of leaving the button looking dead. Touches the disabled-during-request UX too..tfa-input now uses box-sizing: border-box so width:100% textareas (LAN CIDRs, Trusted Proxy CIDRs, Admin emails, Exempt CIDRs, Restore JSON) sit inside their parent panels instead of bleeding the horizontal padding outside.cs/cleartext-storage-of-sensitive-information on the SEC-H3 log line was flagging CIDR strings as if they were credentials. CIDRs are admin-configured network topology, already in cleartext in PluginConfiguration.xml by necessity, and the SEC-H3 diagnostic depends on surfacing the matched CIDR.SelfServiceStepUpMode=Forced by re-authenticating to that IdP in a popup. Subject match against the stored SsoLink is enforced, so signing into a different IdP account doesn't grant step-up./Users/Foo getting 403'd by RequestBlockerMiddleware after docker compose down/up is gone. The plugin now persists SHA-256 hashes of verified tokens to a sidecar JSON, so the in-memory verified-set is rehydrated on every restart instead of locking out every active session.Guid.Empty lockout entries from brute-force testing no longer 500 the Users tab. Two layers: defensive skip in the listing + write-refuse at the store boundary.login.html was missed. Thanks to @duongynhi000005-oss for the PR.console.error, instead of silently looking dead.TreatWarningsAsErrors=true. In-place upgrade — every persisted record (TOTP, passkeys, OIDC links, trusted browsers, paired devices, audit history) carries over. (Shipped 2026-06-05.)BareDeviceIdBypassEnabled flag (default off). The signed trusted-device cookie path is unchanged.ForceHttps=true; existing providers also get https automatically.inject.js and Jellyfin's bundled scripts fixed.SelfServiceStepUpMode, default Forced) — adding/replacing TOTP, recovery codes, app password, or passkey now requires proof of an existing factor.BlockEmptyPasswordLogin (default off) — when true, the plugin refuses empty/whitespace passwords for all users.MarkTokenVerified so the 30-day verified flag short-circuits later SessionStarted re-evaluations regardless of proxy IP rotation.IUserManager.Users property → GetUsers() method rename handled via reflection so the same DLL still loads on 10.11.0–10.11.9.Hardening
Off / Destructive / AllConfigChanges / Everything) re-prompts the admin for 2FA before sensitive operations. Step-up tokens are single-use.RequireTwoFactorToDisable flag — re-prompts for 2FA before a user can disable their own 2FA.Observability
/Dashboard/Overview endpoint — accepts ?range=1w|1m|1y. Backs the chart and the score breakdown.Internationalization
tfa-i18n.js shared helper — tr() / loadTranslations() / applyTranslations() / renderLanguagePicker() / getEffectiveLanguage() + a ready promise so dynamic JS-rendered content waits for the bundle.?lang= → per-user pref → localStorage → server DefaultLanguage → English./TwoFactorAuth/public-config — exposes default language to anonymous pages./TwoFactorAuth/translations/{lang} — embedded-resource endpoint with strong caching.Indefinite device trust (opt-in)
AllowIndefiniteTrust config flag — default off. When off the user-side toggle is hidden entirely.IndefiniteTrust=true.Other fixes
admin-script.js externalized from admin.html so Jellyfin's SPA loadView template-literal stripping no longer breaks the dashboard with a SyntaxError: Unexpected token 'class'./Dashboard/Overview DTOs flattened to force camelCase JSON serialization./Users/Me Id into a module-scope _myUserId so the indefinite-trust toggle works without window.ApiClient (which isn't loaded on the Setup page)._userManager.Users.HasPermission(PermissionKind.IsAdministrator) directly (typed extension method) — fixes the 0/0 admin count regression.window.tfaI18n.ready so it doesn't render in English before the translation bundle loads.Tests: 254/254 pass. Clean build with TreatWarningsAsErrors=true.
Upgrade: in-place — existing TOTP enrollments, passkeys, OIDC links, trusted browsers, paired devices, and audit history all carry over.
Security
Fixes
Require 2FA for all users now has a proper forced-enrollment flow for users who do not have 2FA set up yet.Fixes
Fonts.SegoeUI to "Lato" thinking QuestPDF auto-loaded Lato — it doesn't, the constant is just a name. Skia's fallback found nothing usable inside the Jellyfin Docker container (no system fonts) and rendered every glyph as an empty box.FontManager in the RecoveryCodePdfService static constructor. Works on any container regardless of installed system fonts.Fixes
Fonts.SegoeUI / Fonts.Consolas (Windows-only fonts), which produced a PDF full of empty glyph boxes when generated on a Linux host. Switched to the cross-platform Lato font that QuestPDF bundles by default.UI
confirm() popup. Esc cancels, Enter confirms, click outside cancels.New
linux-x64 shipped working native libs, so Pi / Apple-Silicon-Linux / Alpine deployments couldn't generate the recovery PDF.Architecture.X64 / Arm64 + /lib/ld-musl-* sniff) in RecoveryCodePdfService picks the right RID's natives at startup, copies them next to the plugin DLL where QuestPDF probes, and NativeLibrary.Loads them in dependency order before the first render.InvalidOperationException instead of taking the whole plugin down.Build
build.sh rewritten as a fat-package builder: managed assemblies published once without RID, then per-RID native libs (linux-x64, linux-arm64, linux-musl-x64) bundled into runtimes/<rid>/native/ with a copy at the plugin root..github/workflows/build-multiarch.yml runs the fat build inside a mcr.microsoft.com/dotnet/sdk:9.0 Docker container and publishes the zip + MD5 + SHA256 to a GitHub Release.Credit
v2.1.0.1 in their fork). Thanks Glaucio.Hardening
Secure flag when Jellyfin sits behind a TLS-terminating proxy (Cloudflare, Caddy, nginx, Traefik). Enable by setting TrustForwardedFor + TrustedProxyCidrs in plugin settings.Performance
/web/ index. Disk I/O on every login is now near-zero.No breaking changes. In-place upgrade — existing TOTP enrollments, passkeys, OIDC links, trusted browsers, paired devices, and audit history all carry over.
New
POST /TwoFactorAuth/Passkey/LoginBegin + POST /TwoFactorAuth/Passkey/LoginComplete (anonymous, rate-limited 20/5min per IP).Fix
inject.js now served with Cache-Control: no-store so CDN / reverse-proxy caching doesn't pin old script after plugin upgrades. If you hit this on v2.0 (Cloudflare 24h default), just upgrade — new buttons and hardening now appear immediately without a manual purge.Note: WebAuthn requires a secure context (HTTPS, or plain localhost). The passkey button is hidden when accessing Jellyfin over plain-HTTP LAN IPs — that's a browser rule, not a plugin limit.
Plugin rename from "Two-Factor Authentication" to "Jellyfin Security" (GUID unchanged — upgrades in place). The plugin now spans the whole auth + hardening stack.
New features
AuthenticationProviderId on first link so bridge tokens authenticate correctly.Security hardening
X-Forwarded-Host / Proto only honoured when direct peer is in TrustedProxyCidrs (prevents redirect_uri poisoning)./Oidc/Login (20 per 5 min per IP).JsonSerializer.Serialize for JS context injection + strict CSP + Cache-Control: no-store.returnUrl on sign-in validated to same-origin relative paths./TwoFactorAuth/MyStatus (auth-only) so the user Setup page shows correct TOTP state without admin permission.Bug fixes
/Users endpoint)./web/ corruptionCritical fix for anyone upgrading to 1.4.x. The IndexHtml injection middleware (which inserts <script src="/TwoFactorAuth/inject.js"> into Jellyfin's main index page) was reading the response buffer as UTF-8 text without checking Content-Encoding. When Jellyfin served the pre-gzipped index.html.gz static asset, the middleware read compressed bytes as text, mangled them, and wrote garbage back — the browser then tried to render the binary gzip payload as text, producing a wall of mojibake and the entire web UI refusing to load.
Fix: strip Accept-Encoding from the incoming /web/ request before the response is generated, so Kestrel's static-file handler responds with identity-encoded HTML we can safely inject into. Only applied to the three specific paths the middleware intercepts (/web/, /web, /web/index.html) — other assets still compress normally. Cost: one uncompressed ~50KB HTML per page load. Negligible.
If you're on 1.4.0 or 1.4.1 and the web UI renders as random characters, upgrade.
Critical regression fix. Samsung Tizen (Smart TV) clients behind any reverse proxy (Caddy, nginx, Cloudflare Tunnel, etc) couldn't sign in after upgrading to v1.4 — password entry returned "Invalid username or password" immediately. Root cause: the TV's AuthenticateByName request arrives at the server without an X-Emby-Device-Id header and with a reformatted X-Emby-Authorization that the plugin's parser couldn't extract a deviceId from. No deviceId meant paired-device and registered-device bypasses silently skipped, and the middleware rewrote the auth response as a 2FA challenge — which the native Tizen app can't render, so it just looped on "Invalid".
Fixes:
SessionInfo.DeviceId from Jellyfin's auth response body as a fallback when request headers don't carry a deviceId. That value is always present and authoritative.RegisteredDeviceIds bypass lookup now uses the same UA-hash normalisation as PairedDevices so Tizen webview deviceIds (which include a per-session timestamp suffix that changes on every app restart) match across restarts.If you're on Tizen / Jellyfin for Smart TV and couldn't sign in after v1.4, this release fixes it. No re-pair needed.
New factors
User self-service
autocomplete="one-time-code" on the OTP input — iOS picks codes from Messages.Admin tools
{event, user, ip, timestamp, payload} to any URL. Optional HMAC-SHA256 signature header (X-2FA-Signature: sha256=...) computed over <unix-timestamp>.<body>. The unix timestamp is also exposed as X-2FA-Timestamp so receivers can do replay/skew checks without parsing the JSON body. Events: lockout, new device, recovery used, suspicious login, passkey registered, TOTP rotated, emergency lockout, admin force-logout.Security & integrity
audit.json is detectable. The Diagnostics tab verifies the chain on demand.Tunables
New dependencies bundled (Linux x64 native libs included; Windows / macOS users currently need Docker or to manually supply libsodium):
Critical fixes
deviceId and expiry into the payload. A stolen cookie can no longer be replayed with an attacker-chosen X-Emby-Device-Id header (device substitution bypass). Cookie rotates on every use.(userId, deviceId, token) and single-consume — closes a narrow timing window that could leak a bypass./TwoFactorAuth/Challenge?return= closed — same-origin check with javascript: / data: / file: rejection.High-severity fixes
PairedDevice / TrustedDevice deviceId comparisons are now case-sensitive (Ordinal). Previously OrdinalIgnoreCase allowed case-variant bypass.Guid.Empty user or empty deviceId (phantom-user write prevention).RegisteredDeviceIds capped at 50 per user with 128-char printable-ASCII validation — no more storage-inflation DoS.IsAuthPath is now anchored to ^/Users/… instead of substring Contains — closes a confused-deputy path where a third-party plugin's response could be rewritten as a 2FA challenge.X-Frame-Options: DENY, CSP frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer on all embedded pages (anti-clickjacking).TrustForwardedFor + TrustedProxyCidrs. IPv6 is bucketed by /64 to prevent host-rotation bypass./Verify now has a per-user rate limit (15 per 15 min) in addition to per-IP./Pairings/Initiate input (Username, DeviceName) sanitized against control characters and HTML-significant bytes; length-capped at 64.Medium-severity fixes
inject.js redirects to a hardcoded /TwoFactorAuth/Challenge?token=… path instead of trusting the server body's ChallengePageUrl.TestSmtp admin endpoint no longer echoes ex.Message — full detail goes to server logs.Logout(accessToken) on any live session for that device.PairConfirm records a short-TTL seen-signature set — the same signed pairing token can only be used once.CookieSigner.Verify length-checks signatures before FixedTimeEquals to eliminate the throw/non-throw timing oracle.Quality of life
deviceId and clears stale pending pairings for the same device — browsers that alternate between LAN and Cloudflare (NAT hairpin) no longer accumulate pending entries.IAuthenticationProvider (TwoFactorAuthProvider now resolves IUserManager lazily via IApplicationHost).Contributors who have shipped substantive changes to this plugin:
v2.1.0.1 in their fork. Pi / Apple-Silicon-Linux / Alpine deployments work because of this.Maintained by @ZL154. PRs and issue reports welcome.
2FA for Jellyfin is built and maintained in my spare time. If it's protecting your server and you'd like to support ongoing development, any of these means a lot:
Not expected, just appreciated. Security issues reported responsibly are equally valuable.
MIT — see LICENSE.
| You can | You must | You cannot |
|---|---|---|
| Use on any server, personal or commercial | Keep the copyright notice in any redistribution | Hold the authors liable for damage |
| Fork and modify | Claim author endorsement of your fork | |
| Redistribute, modified or unmodified |
⭐ If you use this plugin, consider starring the repository.
C#
69.2%
HTML
16.0%
JavaScript
13.6%
A Jellyfin plugin that adds native two-factor authentication (TOTP, email OTP) with trusted device tokens, TV device pairing, LAN bypass, and API key bypass. Server-side enforcement — works with all clients including web, mobile, TV, and service integrations like Sonarr/Radarr.
C#
285
401 commits
updated Oct 1, 2026
██████╗ ███████╗ █████╗
╚════██╗██╔════╝██╔══██╗
█████╔╝█████╗ ███████║
██╔═══╝ ██╔══╝ ██╔══██║
███████╗██║ ██║ ██║
╚══════╝╚═╝ ╚═╝ ╚═╝
Comprehensive authentication and hardening for Jellyfin: TOTP, passkeys, email OTP, OIDC/SSO sign-in, brute-force IP banning, impossible-travel detection, per-user IP allowlist, device pairing, trusted-browser cookies, and a full audit log - all from one plugin.
Why this exists: for self-hosters who want a complete auth + hardening layer without standing up a separate identity stack. Full IdPs like Authentik (with OIDC or LDAP outposts) and Authelia work great with Jellyfin and offer features this plugin doesn't - they're often the right call for serious deployments. This plugin is for the case where you'd rather get TOTP, passkeys, OIDC sign-in, brute-force protection, impossible-travel detection, IP allowlist, audit logging, and a proper admin UI as a single Jellyfin plugin — no extra containers, no LDAP outpost, no proxy-auth header juggling, native Jellyfin user model end-to-end.
📖 New: step-by-step guides live in the Wiki — Installation, First-Time Setup, OIDC / SSO, Account Protection, Admin Guide, and Troubleshooting.
You don't have to take my word for it. Every signal below is automated and visible to anyone, including you:
security-extended + security-and-quality C# query packs on every push, PR, and weekly. Green = no security findings..md5 and .sha256 files alongside the .zip so you can verify the artifact wasn't tampered with after upload.If any of these go red, file an issue or DM @zack154 on Discord — fixing
visible trust signals is treated as a high-priority bug.
No more lockouts, and Jellyfin 12 sign-in is finished. Since v2.6.0 the plugin gained a safety net so that uninstalling, disabling, or losing it never strands the accounts that used it; finished OIDC sign-in for TVs and other keyboard-less devices; wired up the GeoIP alerts; and cleared the OIDC→2FA and libsodium crash reports. In-place upgrade from any 2.5.x or 2.6.x, no schema migration or config reset.
v2.6.3
Also since v2.6.0
redirect_uri, pairing QRs and reset links use the right public host, and a device that completed a second-screen approval can optionally be remembered (opt-in, off by default). (v2.6.2)libsodium core dumps (#203/#212, camarigor) — the recovery-code PDF path no longer drags in the wrong native library, and the correct one is repaired for the host's architecture at load. (v2.6.1)Full version history is in the Changelog below and on GitHub Releases.
/TwoFactorAuth/Setup — scans a QR code with an authenticator app and saves recovery codes.SessionStarted event fires. The plugin checks if the user has 2FA enabled./TwoFactorAuth/Login.__2fa_trust cookie is set in the browser. For 30 days, that browser doesn't need 2FA again — but new browsers/devices still do.The standard Jellyfin login page gets a small "Sign in with 2FA" button injected so users with 2FA enrolled can route directly to the plugin's login form. The injected controls are Base URL-aware and are restored after Jellyfin's single-page navigation, including the official Android web shell.
Organized by capability. Per-version history lives in the Changelog and on GitHub Releases.
targetAbi 10.11.0.0) and a .NET 10 build (targetAbi 12.0.0.0) are published under the same catalog entry, and Jellyfin installs the one matching your server automatically. Existing installs auto-update, and upgrading a server from 10.11 to 12 switches builds on the next update.linux-x64, linux-arm64, and linux-musl-x64 native libraries are bundled, so recovery-code PDFs and native crypto work on x86, Raspberry Pi, Apple-Silicon Linux, and Alpine.Authorization: MediaBrowser Token, required once 12 disables legacy authorization.SameSite=Strict).realm_access / resource_access roles.redirect_uri, pairing QRs, and password-reset email link use the server's real public host rather than the address a direct/LAN client reached it on.prompt=login" toggle for IdPs that reject forced re-auth.ISessionManager.SessionStarted — works for all clients, not just web.Off / Destructive / AllConfigChanges / Everything) re-prompts for 2FA on sensitive admin actions, covering the OIDC provider form and every gated admin call.CryptographicOperations.FixedTimeEquals).BareDeviceIdBypassEnabled turned on, which is off by default (see First-time setup).PairDeviceOnSecondScreenApproval, off by default): a sign-in someone approved on another screen, the OIDC login QR on a TV or a Quick Connect code, adds that device to the user's paired devices instead of forgetting it. The record is visible and revocable on the Setup page, and it only waives 2FA where the bare device ID bypass is enabled.

Admin overview — a live security-posture score with an actionable breakdown of every hardening factor.
| Sign in — SSO / 2FA / passkey | Verify your identity (2FA) |
|---|---|
![]() | ![]() |
| Authenticator (TOTP) enrollment | 2FA login portal |
![]() | ![]() |
| Per-user 2FA management | Login audit log |
![]() | ![]() |
| OIDC / SSO providers | Settings |
![]() | ![]() |
Every screen is fully translated across 8 languages (example — German):
Requires Jellyfin 10.11+. The plugin depends on the auth-provider APIs introduced in 10.11. If your server is on 10.10.x or older, the plugin will not appear in the Catalogue after adding the repository — Jellyfin silently filters out plugins whose
targetAbiis newer than the server. Check your version under Dashboard → About; upgrade to 10.11+ if needed.
https://raw.githubusercontent.com/ZL154/JellyfinSecurity/main/manifest.json
Jellyfin 12 moved to .NET 10, and a plugin compiled against 10.11 (.NET 9) will not load there. As of v2.6.0 the plugin ships two builds from one source and both are published in the same catalog entry: a .NET 9 package (targetAbi 10.11.0.0) for Jellyfin 10.11.x and a .NET 10 package (targetAbi 12.0.0.0) for Jellyfin 12.x. Jellyfin's catalog installs the build that matches your server's version, so there is nothing to choose. Installing from the catalog, or letting an existing install auto-update, does the right thing on both, and upgrading a 10.11 server to 12 later switches it to the .NET 10 build automatically.
If you install manually from the Releases page, pick the matching zip: ...v2.6.0.0-jf12.zip on Jellyfin 12, and the plain ...v2.6.0.0.zip on Jellyfin 10.11.
One thing looks different on 12: its default web layout has no side drawer, so the Two-Factor Auth entry lives in the avatar menu (below Profile) and in the user preferences list instead. Jellyfin 12 also disables the legacy authorization headers by default; the plugin's pages and endpoints already send Authorization: MediaBrowser Token (#174, #180), so nothing needs changing on that side.
# Windows
.\build.ps1 -Install
# Linux/macOS
chmod +x build.sh && ./build.sh --install
Download the release ZIP and extract the complete TwoFactorAuth/ directory into <jellyfin-data>/plugins/. Keep every bundled DLL, meta.json, logo.png, and the runtimes/ directory together. Do not cherry-pick only the main plugin DLL: OIDC, passkeys, GeoIP, recovery-code PDFs, and multi-architecture native support require the packaged dependencies.
Release builds validate the plugin version, Jellyfin metadata, required assemblies, case-sensitive file names, and artwork before the ZIP is produced.
Plugin directories by OS:
/config/plugins/TwoFactorAuth/~/.local/share/jellyfin/plugins/TwoFactorAuth/%LOCALAPPDATA%\jellyfin\plugins\TwoFactorAuth\Restart Jellyfin after copying.
https://your-jellyfin/TwoFactorAuth/Setup)From this point, every login from a new browser prompts for a code:
/web with username + password as usualPasskeys replace the 6-digit code with a biometric or hardware tap. They are phishing-resistant (the credential is bound to your exact domain) and require no typing.
Important — server config first. Passkeys require HTTPS AND the WebAuthn Relying Party ID + origin to match the URL the browser is on. In Dashboard → Plugins → Two-Factor Authentication → Settings → WebAuthn / passkeys:
jellyfin.example.com. No https://, no port, no path.https://jellyfin.example.com and https://jellyfin.example.com:8096. Add every URL users actually hit.If you skip this, browsers will refuse to register or use passkeys (Apple Safari is the strictest).
New registrations request ES256, the WebAuthn-required portable algorithm. This avoids the Ed25519/libsodium dependency that caused registration failures on some Linux, ARM, Windows, iOS, and password-manager combinations. Existing valid passkeys continue to work.
Common Android gotchas:
The official Jellyfin Android app and compatible web-shell clients can use the injected 2FA/SSO hand-off. If the buttons are missing after an upgrade, follow the mobile cache steps.
TVs and native clients that cannot display the browser challenge can use device pairing instead, as long as the admin has turned on BareDeviceIdBypassEnabled. It is off by default since v2.5.6, because a device ID is chosen by the client and is not a secret: anyone who has the password and sends a paired device's ID skips the second factor. There is no switch for it on the admin page; set <BareDeviceIdBypassEnabled>true</BareDeviceIdBypassEnabled> in /config/plugins/configurations/Jellyfin.Plugin.TwoFactorAuth.xml and restart Jellyfin. With it off, Trust below still records the device, but the retry gets the 2FA challenge again; use an app password instead.
BareDeviceIdBypassEnabled on, it now works and stays paired until you revoke it on the Setup page.This way a TV/console/media-box that can't type a TOTP code still gets its own credential you can revoke later.
Use app passwords: in Setup → App Passwords → Generate. You get a one-time shown random password. Use it in the app in place of your Jellyfin password. The plugin matches it via PBKDF2 hash and bypasses the 2FA prompt. Each app password can be revoked independently.
On the official Android app, sign in normally and complete the injected 2FA page; after verification the app returns to Jellyfin and the trusted session is recognised on its follow-up requests.
For clients without that browser-capable flow, use the device pairing process described in First-time setup, which needs BareDeviceIdBypassEnabled turned on (see there):
BareDeviceIdBypassEnabled on, it now works until you revoke the device.Alternative: generate an app password in Setup and use it in place of your real password. Useful for older apps or anything that can't tolerate the pairing-request delay, and it works with the default settings.
Use Jellyfin's standard API keys (Dashboard → API Keys) for the connection between these apps and Jellyfin. API key auth bypasses user authentication entirely, so 2FA doesn't apply to that connection.
Seerr and Jellyseerr also let people sign in with their Jellyfin username and password, and the API key does not cover that sign-in: the app forwards the credentials to Jellyfin as that user. An account with TOTP or a passkey gets the 2FA challenge, which these apps cannot complete, so the sign-in fails. Have each user create an app password (the Setup page asks for TOTP on the account first) and use it in the app instead of their Jellyfin password, or sign in with Quick Connect where the app offers it (Seerr 3.4 and later). Putting the app's address in the LAN bypass list also works, but then every sign-in that arrives through the app skips 2FA. Do not add the app's address to Trusted proxy CIDRs instead: with proxy support on, Seerr forwards the left-most X-Forwarded-For address it received, which the client can write itself, so the LAN bypass would follow whatever address the client claims.
The admin dashboard at Dashboard → Plugins → Two-Factor Authentication has 5 tabs:
Per-user 2FA status: TOTP on/off, trusted device count, recovery codes remaining, email address (for OTP), lockout status.
Every trusted device across all users with last-used time and expiry. Revoke any to force 2FA on that browser's next login.
Pending TV/native-client pairing requests. Approve or deny each request and show its QR code from the dashboard.
Paginated, filterable login attempt history. Tracks success, failures, lockouts, bypasses, and challenge issuances. Choose Newest first or Oldest first; the selected order remains active while navigating the dashboard.
RequireTwoFactorToDisable (re-prompts before a user can self-disable 2FA), StepUpLevel (which admin actions re-prompt for 2FA), AllowIndefiniteTrust (gates the user-side opt-in for never-expiring trust), DefaultLanguage (server-wide UI default; users can still override per-user)Lets users sign in with Google / Microsoft / Authelia / Authentik / Keycloak / PocketID / Cloudflare Access / etc. instead of (or alongside) a Jellyfin password. 2FA-less accounts work too — SSO replaces the password.
Matching logic when a user signs in via OIDC:
sub) → signs inImplicit email/username linking is refused for administrators, ambiguous matches, a different subject already linked to that provider, or one IdP subject already linked elsewhere. Admins link explicitly from their Setup page.
Linking from the Setup page (v2.5.13, #95): any signed-in user — including admins — can link a new provider from /TwoFactorAuth/Setup → Linked Sign-In Methods → "Link a new provider". It opens the IdP in a popup and links by subject to the current account, so admins can link without tripping the anti-takeover guard that blocks implicit admin linking during a normal sign-in.
1. Register a Google OAuth client
2. Add the provider in Jellyfin
email. Save.redirect_uri to register at the IdP — it's https://YOUR-JELLYFIN-HOSTNAME/TwoFactorAuth/Oidc/Callback/<slug>, where <slug> is derived from the Display name you chose (e.g. Google → google, Login with Google → login-with-google). Go back to Google Cloud Console → Credentials → your OAuth client → add this exact URL to Authorised redirect URIs and save. If the slug doesn't match what's registered, the IdP returns redirect_uri_mismatch and sign-in fails (issue #28).3. Make sure each Jellyfin user has their Gmail configured
/TwoFactorAuth/Setup), or4. Done. Sign out and the login page now shows a "Sign in with Google" button. Click → Google consent → bridge page → signed in.
| Preset | Discovery auto-filled | Notes |
|---|---|---|
| ✅ | Username claim: email | |
| Microsoft / Entra | ✅ | Replace common in discovery URL with tenant ID for single-tenant apps |
| Apple | ✅ | Returns email only on first sign-in; no email_verified claim |
| Authelia | — | Paste https://authelia.domain/.well-known/openid-configuration |
| Authentik | — | Copy discovery URL from provider details in Authentik admin; see expired or incompatible signing certificates |
| Keycloak | — | https://keycloak.domain/realms/<realm>/.well-known/openid-configuration |
| PocketID | — | https://pocketid.domain/.well-known/openid-configuration |
| Cloudflare Access | — | SaaS → OIDC app → discovery URL ends /cdn-cgi/access/sso/oidc/<app-id>/.well-known/openid-configuration |
| GitHub | ❌ | OAuth2 only, not OIDC — not yet supported |
| Discord | ❌ | OAuth2 only, not OIDC — not yet supported |
groups / roles claim contains at least one of these. (v2.5.17) Keycloak nests roles under realm_access / resource_access rather than a flat claim — the plugin now reads those too (here, and for Admin groups + role→library mapping); request the built-in roles scope on the provider and enable "Add to ID token" on the realm-roles mapper.email_verified: true) matches a Jellyfin user's configured email is linked to that account instead of creating a duplicate.groups claim matches an entry here is granted Jellyfin admin on sign-in. Grant-only (never auto-revokes); every elevation logged at WARN. Only enable for an IdP you fully control — a compromised IdP that controls the groups claim could elevate any account.amr claim indicates MFA (mfa, hwk, otp, sca)end_session_endpoint so the IdP session ends too, instead of leaving it live for the next visitor to that browser. Off by default. Requires the provider to publish end_session_endpoint in its discovery document; if it doesn't, sign-out silently stays local. The plugin sends client_id and not id_token_hint, since it never retains id_tokens. Keycloak (>= 18) and Authentik accept that form, and an OP that insists on id_token_hint will show its own generic sign-out page instead. Only the browser bridge participates; native-app sign-in keeps stock local sign-out.https:// URL sent as post_logout_redirect_uri so the browser comes back to Jellyfin instead of stopping on the IdP's logged-out page. Leave empty (the default) to stop there, which is what most people want. Register the exact URL at the provider first, because most OPs reject the entire sign-out request when it carries an unregistered redirect. {server}/TwoFactorAuth/Oidc/LoggedOut is provided for this.prompt=login) (v2.5.19, #119, opt-in) — compatibility switch for IdPs such as Authentik that reject prompt=login. Leave it off unless needed because forced re-authentication protects account-link, step-up, and onboarding validation flows from silently accepting an existing IdP session.169.254.1.2/32. /0 is rejected; each listed CIDR bypasses safety checks for matching addresses, so only list addresses you own. See OIDC private / VPN / LAN endpoints.Auto-bans source IPs that hammer the login endpoint. Fail2Ban-style, entirely in-process — no external service needed.
Configure: Jellyfin Security → Settings → "Brute-Force Protection":
Always exempt: LAN-bypass CIDRs, trusted-proxy CIDRs, anything in the exempt list.
Manage bans: Jellyfin Security → IP Bans tab lists all active bans with expiry. Click "Unban" to clear. You can also manually ban an IP here (e.g. "someone who's been guessing").
Bans persist across restarts via <config>/plugins/configurations/TwoFactorAuth/ip-bans.json.
Flags sign-ins where the geographic distance vs. elapsed time exceeds commercial-jet cruise speed. London → Tokyo in 30 minutes ≈ Mach 20: notification fires.
Requires: MaxMind GeoLite2-City.mmdb. Free account, download the City DB, drop it in /config/geoip/, paste the path in Settings → Impossible-Travel Detection. The path must be the one the Jellyfin process sees (inside the container, for Docker) and readable by the user Jellyfin runs as; the Diagnostics tab tells you which of those is not the case.
Signal path: Triggers the same Notification channels the plugin already uses (ntfy, Gotify, webhook, admin emails). Includes distance, duration, inferred speed, and country hop in the message.
Off by default; enable in Settings once the city DB is in place.
Pin a user account to specific CIDRs. Empty = no restriction (default). Useful for admin accounts where lateral exposure hurts most.
Configure (user self-service): Setup page → IP Allowlist card → one CIDR per line → Save.
Configure (admin, per user): PUT /TwoFactorAuth/IpAllowlist/User/{userId} (UI not wired in yet; edit the user JSON or use the API).
⚠ Self-lockout risk: if you typo a CIDR, you can't sign in. Recover by editing /config/plugins/configurations/TwoFactorAuth/users/<your-guid>.json and clearing IpAllowlistCidrs.
Re-prompts the admin for a fresh 2FA challenge before sensitive operations. Defends against a logged-in session being hijacked or left unattended on a workstation.
Configure: Jellyfin Security → Settings → Hardening → Step-up level:
| Level | What re-prompts |
|---|---|
Off | Nothing. (Default — opt in deliberately.) |
Destructive | Deleting users, wiping 2FA state, rebuilding the audit chain, removing OIDC providers. |
AllConfigChanges | All of Destructive, plus toggling settings, editing SMTP / push / brute-force / impossible-travel config. |
Everything | All of AllConfigChanges, plus viewing audit log, listing IP bans, exporting config. (Strongest — least convenient.) |
How the flow looks:
Related setting: RequireTwoFactorToDisable — when on, users can't disable their own 2FA without entering a fresh code first. Stops a stolen session cookie from being used to switch 2FA off.
Back up or migrate plugin configuration (settings, OIDC providers, trusted CIDRs, brute-force config, etc.) without leaking secrets.
Export (admin):
.json.enc envelope. Treat it like a password — its strength is the passphrase's.Import (admin):
.json.enc file → enter the same passphrase → review the preview of what will change → confirm.Crypto envelope (so you can audit it):
{ "v": 1, "salt": "...", "nonce": "...", "ct": "...", "tag": "..." } — future versions can change parameters without breaking decryption of older exports.⚠ No back door: a lost passphrase means the export is unrecoverable. The plugin author cannot decrypt your file. Store the passphrase in your password manager separately from the export file.
A 12-factor security score (raw 130 points, normalized to 100) and a live auth-activity chart on the admin dashboard.
In v2.5.20, posture checks initialise independently on Jellyfin 10.11.11. A failed or unavailable diagnostic is reported for that factor without leaving the score on Computing..., and dashboard tab navigation remains usable.
| Factor | Points | What it checks |
|---|---|---|
| Coverage | 30 | % of live users enrolled in 2FA (deleted accounts are no longer counted, v2.5.18) |
| Admin coverage | 20 | All admins specifically have 2FA on |
| Enforcement | 15 | RequireForAll is on |
| Audit chain | 10 | Hash chain is intact (no breakage) |
| IP ban | 8 | Brute-force banning enabled with sane threshold |
| Impossible travel | 7 | Functional — requires GeoIpCityDbPath set to a valid MaxMind file |
| HIBP | 5 | Have-I-Been-Pwned password check enabled |
| Clean 7-day audit | 5 | No failed admin sign-ins in the last 7 days |
| Require-to-disable | 8 | RequireTwoFactorToDisable is on |
| Step-up | 7 | StepUpLevel is Destructive or stronger |
| Webhook | 5 | Push notifications (ntfy / Gotify / webhook) configured |
| Recovery codes | 5 | At least one user has generated recovery codes |
Admin dashboard → Overview tab shows a stacked-area chart of successful / failed / blocked sign-ins.
Every user-visible string in the setup, login, challenge, OIDC onboarding, admin pages, and injected desktop/mobile sidebar is translatable. Ships with 8 first-class languages at full key parity (847 keys each).
| Language | Locale | Display name in picker |
|---|---|---|
| English | en | English |
| Deutsch | de | Deutsch |
| Español | es | Español |
| Français | fr | Français |
| Italiano | it | Italiano |
| 日本語 | ja | 日本語 |
| Português | pt | Português |
| 中文 | zh | 中文 |
How the active language is chosen (first match wins):
?lang=de.DefaultLanguage.The injected dashboard entry updates its label live when Jellyfin's language changes and is available in both desktop and mobile navigation.
Native-name picker — the picker shows each language in its own script ("Deutsch", "日本語", "中文") rather than locale codes, so a user who only reads Japanese can find their language without reading English.
Implementation notes (for translators / contributors):
src/Jellyfin.Plugin.TwoFactorAuth/Pages/translations/<lang>.json and are served via /TwoFactorAuth/translations/{lang} with strong caching.tfa-i18n.js helper exposes window.tfaI18n.tr(key, fallback), loadTranslations(lang), applyTranslations(root), renderLanguagePicker(container), getEffectiveLanguage(), and a ready promise so dynamic JS-rendered content doesn't render in English before the bundle loads./TwoFactorAuth/public-config exposes the server-wide default language to anonymous pages (login / challenge) without leaking other config.Want to add a language? Copy translations/en.json → translate → drop in translations/<your-locale>.json. The picker auto-discovers new files. Pull requests welcome.
Lets a user mark a specific trusted browser or paired device as "trusted forever" instead of "trusted for 30 days." Useful for a personal phone or home TV where the user would rather have one less prompt and accept the residual risk if the device is lost.
Admin gate (default off): Jellyfin Security → Settings → Hardening → AllowIndefiniteTrust. When off, the user-side opt-in toggle is hidden entirely — no way to enable per-device. When on, users see an Indefinite trust toggle on each of their trusted browsers / paired devices.
User opt-in (per device):
Revoke / undo: same toggle off. Or revoke the device entirely from Setup → Trusted Devices.
⚠ Tradeoff — an indefinite-trust device is your weakest link. If someone steals the laptop, that browser is signed in until you revoke it. Don't enable on shared / borrowed machines, and revoke immediately on device loss. The admin gate exists so org admins can keep this off entirely if their threat model doesn't tolerate the tradeoff.
Closes the stolen-session takeover path. Before v2.5.6, an attacker who hijacked an authenticated browser cookie could silently enroll their own authenticator (add a passkey, generate a new TOTP secret, regenerate recovery codes) without ever proving they were the legitimate user — the original 2FA only gated login, not factor changes. v2.5.6 closes that.
Setting: Jellyfin Security → Settings → Hardening → Hardened security for users (factor changes). Tri-state:
Covered mutations — adding/replacing TOTP, regenerating recovery codes, creating an app password, adding/removing a passkey, enabling/disabling email OTP. All gated.
Proof of factor — the step-up prompt accepts any of:
Step-up tokens are single-use, 60-second TTL, and bound to the requesting user — they can't be replayed or reused for a second mutation.
Lets a user satisfy the hardened self-service step-up by re-authenticating to a linked OIDC provider, instead of needing a TOTP / passkey / recovery code. Useful for users whose only configured factor is OIDC (common in OIDC-only deployments — see "Hide built-in login buttons" below).
How it works:
GET /TwoFactorAuth/Oidc/MyLinks).prompt=login so the IdP must actually re-authenticate the user — silent SSO confirmation is rejected./TwoFactorAuth/Oidc/Callback/{providerId} endpoint. The state token marks this as a step-up flow.sub matches the user's stored SsoLink for that provider. Both must match. Signing into a different IdP account doesn't grant step-up.postMessages it back to the opener (same-origin only), and closes the popup.Security guards:
prompt=login defeats a hijacked-session attacker who clicks "Sign in with X" hoping for a silent confirmation.SsoLink defeats a hijacked-session attacker who happens to have their own account at the same IdP.postMessage target is restricted to window.location.origin, never '*'.The "Verify with X" buttons only appear in the step-up modal when the user has at least one OIDC link; they don't add UI for users who don't use OIDC.
For OIDC-only deployments where every user signs in through your IdP and the plugin's injected sign-in shortcuts add noise. Two independent admin toggles in Settings → Hardening:
inject.js adds to Jellyfin's main login page.Each is independent — pick any combination. Configured OIDC provider buttons stay visible regardless of these flags.
Login-link placement & Forgot-password (v2.5.16, #79, ZEROX7): an opt-in Settings → Hardening → "Show the SSO / 2FA / passkey links below the Use Quick Connect button" toggle (default off) moves the injected links beneath Quick Connect instead of directly under Sign In. Separately, the native "Forgot password" link is now hidden automatically when there's no visible password field (e.g. OIDC-only login), since there'd be nothing to recover.
⚠ The /TwoFactorAuth/Login page still works directly even when both toggles are on. Admins/fallback users can always reach it by URL, so you don't lock yourself out of the plugin's login flow if your IdP becomes unreachable.
Lets you point the plugin at an IdP that lives on a private network (Tailscale, Wireguard, LAN-only Authentik / Authelia / Pocket ID, etc.). Without this toggle, v2.5.5's SSRF guard rejects any OIDC discovery URL that resolves to an RFC1918 / loopback / link-local address, or that uses plain http.
Setting: per-provider, in the OIDC provider edit form → Allow private / VPN / LAN endpoints (marked Advanced, default off).
Granularity: per-provider. A public Google + a private Authentik can coexist — Google keeps the strict SSRF guard, Authentik gets the bypass. The toggle scopes to ONE provider's discovery / token / userinfo / jwks fetches; other providers are unaffected.
⚠ Trade-off — enabling this for a provider whose discovery URL gets tampered with would let an attacker pivot the plugin into your internal services (e.g. AWS IMDS at 169.254.169.254, internal admin APIs, the Docker daemon socket via host networking). Only enable for IdPs you intentionally host on private networks where the network boundary IS the security boundary.
Finer-grained alternative (v2.5.16, #103, andrewdunndev) — even with "Allow private networks" on, the guard still blocks link-local addresses (169.254.0.0/16, the IMDS range), which catches the rootless-Podman host-gateway 169.254.1.2 (host.containers.internal). Rather than open the whole private bypass, use the per-provider "Additional allowed CIDRs" field to allowlist exactly that one address (169.254.1.2/32). It's surgical (/0 and out-of-range prefixes are rejected) and each listed CIDR only bypasses the check for matching addresses.
The OIDC spec doesn't let admins mix-and-match per-endpoint — the IdP's discovery document dictates which token / userinfo / jwks URLs the plugin fetches, and they all live in the same network as discovery. So per-provider is the natural granularity.
Closes the "session permanently 403'd after restart" issue (#52). Before v2.5.7, the plugin tracked which access tokens had completed 2FA in an in-memory dictionary. After a docker compose down/up (or any process restart), that dictionary was empty — but the user's Jellyfin auth token was still valid in Jellyfin's DB. The failsafe BlockToken then triggered on every SessionStarted reconnect, and RequestBlockerMiddleware 403'd every API call. The user couldn't even reach /Users/Me/Logout — they had to wipe local storage.
Fix: SHA-256 hashes of verified tokens persist to {plugin-data}/verified_tokens.json. On every restart, the hashes are loaded back into the in-memory set, so already-verified sessions stay verified.
What's stored:
What's NOT stored — never the plaintext token, never user ids, never device ids. Just hash + expiry.
Operational signal — after the first restart following a successful login, the log emits [2FA] Loaded N verified-token hashes from /config/plugins/configurations/TwoFactorAuth/verified_tokens.json. That confirms persistence is active.
Email OTP requires SMTP credentials. Common providers:
SMTP Host: smtp.gmail.com
SMTP Port: 587
Use SSL/TLS: ✓
SMTP Username: your-email@gmail.com
SMTP Password: <generate at https://myaccount.google.com/apppasswords>
From Address: your-email@gmail.com
From Name: Jellyfin 2FA
SMTP Host: mail.example.com
SMTP Port: 587 (STARTTLS) or 465 (implicit TLS)
Use SSL/TLS: ✓
Email OTP needs the user's email address. In Admin → Users, edit each user's email field. The plugin doesn't auto-pull from Jellyfin user metadata (Jellyfin's User entity exposes email inconsistently across versions).
Sign in via /TwoFactorAuth/Login. In the code field, enter one of your recovery codes (format: XXXXX-XXXXX). Click "Use a recovery code instead" if your authenticator app field is showing. (v2.5.18) If you're already signed in and hit the "Verify your identity" screen, it now shows a Recovery tab too (whenever you have unused recovery codes), so you can fall back to a recovery code mid-session.
SSH into the Jellyfin server and edit the user data file:
# Path
/config/plugins/configurations/TwoFactorAuth/users/{userId}.json
# Set:
"TotpEnabled": false,
"TotpVerified": false,
"EncryptedTotpSecret": null,
"RecoveryCodes": [],
"TrustedDevices": []
Restart Jellyfin. The user can now log in normally and re-enroll.
InvalidAuthProvider)Signing in with a passkey, creating an app password, or signing in through SSO moves that account onto the plugin's own sign-in provider. If Jellyfin then stops loading the plugin (for example a build made for another Jellyfin version, or missing files), it has no provider for those accounts and refuses their password, the correct one included, administrators too. The Jellyfin log shows:
User alice was found with invalid/missing Authentication Provider Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider. Assigning user to InvalidAuthProvider until this is corrected
Authentication request for alice has been denied (IP: ...).
Uninstalling or disabling the plugin from Dashboard → Plugins no longer causes this: the plugin first hands those accounts back to Jellyfin's own provider, and takes them back the next time it starts. The steps below are for a plugin that does not load at all, which never gets that chance.
Get the plugin loading again, if you can. Nothing is lost: the accounts sign in as before as soon as it loads. If Dashboard → Plugins lists it as Disabled, enable it and restart Jellyfin. With no administrator able to sign in, set "status": "Active" in the plugin's meta.json (in its folder under /config/plugins/) and restart Jellyfin.
If an administrator can still sign in, open Dashboard → Users, pick the account and press Save without changing anything. With the plugin not loaded, Jellyfin offers only its own provider, so saving the profile moves the account onto it. If another sign-in plugin (LDAP, for example) is installed, the page shows Authentication Provider: pick the one the account should use.
If no administrator can sign in, move the accounts in Jellyfin's database. Stop Jellyfin, run the commands below (with the sqlite3 tool) and start Jellyfin again:
# In the official Docker image the database is data/jellyfin.db inside the folder mounted at /config.
# Elsewhere, find it with: find / -name jellyfin.db 2>/dev/null
cp /path/to/jellyfin.db* /path/to/backup/
# The accounts on the plugin's provider
sqlite3 /path/to/jellyfin.db "SELECT Username FROM Users WHERE AuthenticationProviderId = 'Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider';"
# Move them to Jellyfin's own provider; prints how many were moved
sqlite3 /path/to/jellyfin.db "UPDATE Users SET AuthenticationProviderId = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider' WHERE AuthenticationProviderId = 'Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider'; SELECT changes();"
The plugin does not undo steps 2 and 3. Once it runs again, an account moved this way still gets its second factor, but its app passwords are refused until it creates a new one; after that, its older app passwords work again too.
To always have a way in, keep one administrator account that never signs in with a passkey, never creates an app password and never signs in through SSO. That account stays on Jellyfin's own provider, so it can sign in and run step 2 for the others even when the plugin does not load.
The Android app and mobile browsers can retain Jellyfin's web shell from before the plugin was installed or upgraded. The plugin now prevents its patched index.html from being cached, but an older shell already stored on a device may still need one manual refresh:
/jellyfin, make sure the device opens that full URL, for example https://media.example.com/jellyfin.<your Jellyfin URL>/TwoFactorAuth/inject in the same browser. It should return JavaScript, not a 404 or a proxy error./web/index.html, /web/, or /TwoFactorAuth/*.After one successful refresh, the login buttons and the Two-Factor Auth entry (sidebar on 10.11, avatar menu on Jellyfin 12) should appear normally. Clearing the full app storage is not normally required and will sign the device out.
From v2.5.21 this message is much rarer, and when it does appear it now tells you what to fix. Instead of one generic string, the sign-in page reports the actual cause — an expired IdP certificate, a signing-key mismatch, a Client ID mismatch, clock drift, or an unsupported signing algorithm. Follow whatever it says; the full technical detail is in the Jellyfin server log.
Two changes in v2.5.21 are worth knowing about:
Expired signing certificates no longer block sign-in. Authentik generates self-signed signing certificates that expire after one year and does not rotate them automatically. Earlier versions rejected the token once that certificate lapsed (IDX10249), even though the signature itself was still valid — an outage with no security benefit, since the plugin fetches the JWKS over TLS from the issuer's own discovery endpoint and that, not the certificate's validity window, is the trust anchor. The signature is still fully verified on every sign-in; only the certificate's expiry date is no longer treated as fatal. You should still renew it (System → Certificates in Authentik), but a lapsed certificate will not lock your users out.
If the error mentions the signing algorithm, the provider is signing with HMAC (HS256) rather than a key pair. In Authentik that means the provider has no Signing Key selected. Pick an RSA certificate there. The plugin accepts the standard asymmetric OIDC algorithms (RS256/384/512, ES256/384/512, PS256/384/512) and deliberately refuses HMAC and none — that allowlist is what closes the RS256→HS256 algorithm-confusion attack, so it is not configurable.
If the error names a RSA-OAEP / RSA-OAEP-256 (or another RSA-*, ECDH-ES*, *KW, or dir) algorithm, that is a key-encryption algorithm, not a signing one: your IdP is returning an encrypted ID token (JWE), and the plugin, like most OIDC clients, verifies a signed token (JWS) against your IdP's published keys rather than decrypting one. In Authentik this is the provider's Encryption Key under Advanced protocol settings — set it to blank / --------- so Authentik returns the plain signed JWT the plugin can verify. Keep the Signing Key set (an RS256 certificate is fine); it is only the Encryption Key that causes this. Encrypting the ID token buys little here anyway, since the exchange is already over TLS and the token is validated server-side. Encrypted (JWE) ID tokens are not supported today. (Reported in discussion #188.)
If you changed or rotated the signing key and sign-in still fails, restart Jellyfin so the JWKS cache picks up the new key. See Authentik's certificate management and OAuth2/OIDC provider documentation.
Permissions-Policy and synchronous XHRIf you deployed the Permissions-Policy header from Jellyfin's official nginx example, Chromium-based browsers log:
Error with Permissions-Policy header: Unrecognized feature: 'ambient-light-sensor'.
Error with Permissions-Policy header: Unrecognized feature: 'battery'.
Error with Permissions-Policy header: Unrecognized feature: 'document-domain'.
Error with Permissions-Policy header: Unrecognized feature: 'interest-cohort'.
[Violation] Permissions policy violation: Synchronous requests are disabled by permissions policy.
The first four are harmless: those features were removed from the spec, so Chromium warns and ignores them. Dropping them from the header silences the noise:
add_header Permissions-Policy "accelerometer=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always;
The sync-xhr violation does not come from this plugin. Jellyfin Security issues no synchronous XMLHttpRequest anywhere — every request it makes, on every page, uses fetch() — so you can keep the strict sync-xhr=() baseline. The violation is raised by another plugin's bundled jQuery calling $.ajax({ async: false }), and the culprit is named on the line above the inject.js frame in the stack trace.
Jellyfin Security patched XMLHttpRequest.prototype.send globally, which put inject.js in the stack of those third-party calls and made it look responsible. As of v2.5.21 the plugin passes synchronous requests straight through untouched, so the stack trace now points at the real caller. If a plugin genuinely needs synchronous XHR, either report it upstream or relax the header to sync-xhr=(self) for that deployment.
Disable the plugin without uninstalling:
# Edit
/config/plugins/configurations/Jellyfin.Plugin.TwoFactorAuth.xml
# Set
<Enabled>false</Enabled>
Restart Jellyfin. All 2FA enforcement turns off; users can log in normally.
Enabled is read by the plugin itself, so it only helps while Jellyfin still loads the plugin. If the plugin does not load at all and accounts are refused, see Sign-in refused after the plugin stopped loading.
Fixed at the source in v2.4.12 — on a current build you should not hit this. Requests blocked pending 2FA now return 403, not 401, and SWAG's default
nginx-unauthorizedjail only counts 401s, so a normal 2FA login no longer trips a ban (issue #36). The injected script also short-circuits the follow-up API calls so the browser stops hammering the server while the challenge is open. The tuning below is kept only for older builds, or if you run a custom jail that also bans on 403.
If you run Jellyfin behind SWAG (linuxserver.io's all-in-one nginx + fail2ban + Let's Encrypt container) or any other stack with a fail2ban jail watching for HTTP 401s, on a build older than v2.4.12 you may have seen this symptom:
ERR_CONNECTION_REFUSEDWhy this happens. When 2FA enforcement is on and a user logs in, the plugin's RequestBlockerMiddleware 401s every post-login API call from the browser (/Sessions/Capabilities/Full, /DisplayPreferences/usersettings, /socket, /System/Endpoint, etc.) until the user completes 2FA — that's roughly 15 401s in a few seconds per legitimate login.
SWAG's default nginx-unauthorized fail2ban jail watches the nginx access log for any 401 response code (regardless of which backend produced it) and bans the source IP after 5 in 10 minutes. A single 2FA login trips it. The ~15-minute recovery cycle matches the jail's default bantime = 600.
The "everything else breaks" symptom depends on what IP fail2ban actually bans:
Fix. Drop this into /config/fail2ban/jail.d/jellyfin.local:
[nginx-unauthorized]
maxretry = 30
findtime = 600
That changes "ban after 5 401s in 10 min" → "ban after 30 401s in 10 min." A normal 2FA login generates ~15 401s, so 30 gives ~2× headroom while still catching real brute-force (hundreds of 401s per minute).
Scale by user count — fail2ban counts per source IP, and if you're behind Cloudflare or a similar CDN, ALL your users share the same source IP from fail2ban's view. Simultaneous logins compound:
| Users on the server | Recommended maxretry |
|---|---|
| 1 (solo) | 30 |
| 2–3 (small household) | 50 |
| 4–6 (family) | 100 |
| 10+ (community / extended) | 150 or enabled = false |
Restart SWAG (docker restart swag or your equivalent) after the change.
Alternative — disable the jail entirely. If you'd rather not patch fail2ban:
[nginx-unauthorized]
enabled = false
You lose protection against generic 401-burst attacks on all apps behind SWAG (not just Jellyfin), but the other default SWAG jails (nginx-http-auth, nginx-badbots, nginx-botsearch, nginx-deny) still cover the common brute-force vectors.
Why this isn't strictly a plugin bug. The plugin behaves correctly per HTTP/OAuth (401 on unverified tokens). SWAG's fail2ban behaves correctly per brute-force-protection norms. The collision sits in the gap between the two — fail2ban can't tell a legitimate 2FA enforcement burst from an attack just by reading status codes in the access log. This was resolved at the source in v2.4.12 (issue #36): the blocked-request response is now 403, which the default nginx-unauthorized jail does not count, so a normal 2FA login no longer trips it. The jail-threshold tuning above is only needed on older builds or a custom jail that also bans on 403.
The plugin uses 5 ASP.NET Core middleware components plus an ISessionManager.SessionStarted event handler:
IndexHtmlInjectionMiddleware — injects the Base URL-aware login/dashboard script into Jellyfin's index.html, prevents stale web-shell caching, and restores controls after single-page navigationTrustCookieMiddleware — checks the __2fa_trust cookie on auth requests; if valid, marks the user as pre-verified for the upcoming sessionTwoFactorEnforcementMiddleware — inspects responses from auth endpoints (catches the auth response shape regardless of which Jellyfin route was used)RequestBlockerMiddleware — blocks API requests from authenticated users who haven't completed 2FA yet (returns 401)AuthenticationEventHandler (hosted service) — subscribes to SessionStarted; if a session for a 2FA-enabled user starts without verification, the user is added to the blocker's blocklist. Repeated native-client events for the same logical user/device are deduplicated before notifications are sent.Persistent state:
users/{userId}.json — per-user TOTP secret (AES-GCM encrypted), recovery codes (per-code-salted PBKDF2-HMAC-SHA256, 600k iterations), trusted devices, lockout statesecret.key — 32-byte AES-GCM key for TOTP secret encryptioncookie.key — 32-byte HMAC-SHA256 key for trust cookie signingaudit.json — login attempt logAll file writes use atomic write-then-rename so crashes mid-write don't corrupt user state.
GET /TwoFactorAuth/Login — login page (HTML)
GET /TwoFactorAuth/Setup — enrollment page (HTML)
GET /TwoFactorAuth/Challenge?token=... — challenge page (HTML)
GET /TwoFactorAuth/inject — cache-resistant login/dashboard injection script
POST /TwoFactorAuth/Authenticate — username + password + code login
POST /TwoFactorAuth/Verify — verify code against challenge token
POST /TwoFactorAuth/Email/Send — request email OTP for current challenge
POST /TwoFactorAuth/Setup/Totp — generate TOTP secret + QR (auth)
POST /TwoFactorAuth/Setup/Totp/Confirm — confirm TOTP enrollment (auth)
POST /TwoFactorAuth/Setup/Disable — disable 2FA for self (auth)
POST /TwoFactorAuth/RecoveryCodes/Generate — generate recovery codes (auth)
GET /TwoFactorAuth/RecoveryCodes/Status — count remaining (auth)
GET /TwoFactorAuth/Devices — own trusted devices (auth)
DELETE /TwoFactorAuth/Devices/{id} — revoke own trusted device (auth)
POST /TwoFactorAuth/Devices/Register — pre-register device ID (auth)
RequiresElevation)GET /TwoFactorAuth/Users — all users with 2FA status
POST /TwoFactorAuth/Users/{id}/Toggle — enable/disable 2FA for user
GET /TwoFactorAuth/AllTrustedDevices — devices across all users
DELETE /TwoFactorAuth/Users/{userId}/Devices/{deviceId} — admin revoke
GET /TwoFactorAuth/AuditLog — login history
GET /TwoFactorAuth/Pairings — pending TV pairings
POST /TwoFactorAuth/Pairings/{code}/Approve — approve pairing
POST /TwoFactorAuth/Pairings/{code}/Deny — deny pairing
GET /TwoFactorAuth/ApiKeys — list managed API keys
POST /TwoFactorAuth/ApiKeys — generate new API key
DELETE /TwoFactorAuth/ApiKeys/{id} — delete API key
POST /TwoFactorAuth/Sessions/{id}/Revoke — revoke an active session
| Threat | Mitigation |
|---|---|
| Stolen password (no 2FA bypass) | All sessions blocked until 2FA completed; correct password alone gives 401 on every API call |
| TOTP brute force on the 6-digit code space | Per-IP rate limit (10/min on verify, 10/min on auth), per-challenge attempt limit (5), per-user lockout (5 failures → 15min) |
| Stolen recovery code | Marked used immediately on validation regardless of password outcome — can't be retried |
| Stolen trust cookie | HMAC-SHA256 signed with persistent server-side key; HttpOnly, Secure, SameSite=Strict; tied to a server-side trust record (revocable) |
| Account enumeration | Identical "invalid credentials" message whether password is wrong, user doesn't exist, or 2FA code is wrong |
| Disk corruption mid-write | Atomic write-then-rename for all user state files |
| TOTP secret theft from disk | AES-GCM encrypted with persistent 32-byte key |
| Replay attacks on TOTP | Used time-steps tracked per user |
| Timing attacks | CryptographicOperations.FixedTimeEquals on all secret comparisons |
| OIDC account-link confusion | Stable-subject links take precedence; implicit email/username matching is non-admin only, opt-in where applicable, and refuses ambiguous or conflicting identities |
| Stale OIDC onboarding page/session theft | Live IdP revalidation plus a short-lived single-use proof is required before setting the password; cancel revokes the temporary server session |
| Expired or unapproved OIDC signing key | Standard issuer/audience/nonce/certificate validation plus an explicit RSA/ECDSA/RSA-PSS algorithm allowlist |
| Service integrations breaking | Standard Jellyfin API keys bypass user auth — Sonarr/Radarr unaffected |
| Authelia/Authentik breaking native apps | Native plugin, no proxy dependency |
/TwoFactorAuth/Login).redirect_uri, pairing QRs, and password-reset email link use the server's real public address instead of the request host (a LAN address for a TV that reaches Jellyfin directly). Empty by default, falling back to Jellyfin's own published server URI; the value comes only from admin config, never a request header (#219).RPID/RPName rename) and routine test tooling; QuestPDF and IdentityModel pins deliberately held (#229).TwoFactorRequired response and continues to the TOTP prompt instead of stopping on "Sign-in could not be completed. Error: HTTP 401". Applies to both the browser callback and the in-app webview (#205, fixes #204).libsodium.so core dumps (seen on the linuxserver.io image on Jellyfin 12): the recovery-code PDF path no longer eagerly loads libsodium, and the plugin repairs the architecture-correct native library at load; libsodium is loaded only when a passkey is actually used (#212, fixes #203).targetAbi 10.11.0.0) for Jellyfin 10.11.x and a .NET 10 package (targetAbi 12.0.0.0) for Jellyfin 12.x, both published in the one manifest.json under the same GUID so Jellyfin's catalog routes each host to its build (#196, #172).POST /Users/{userId}/Authenticate endpoint was not gated (and also skipped empty-password blocking and per-account lockout), and the SSO waiver was a string-prefix test rather than a live token lookup. Both are fixed; valid credentials were always still required (reported privately by @camarigor).Authorization: MediaBrowser Token header alongside the legacy X-Emby-Token, required once legacy authorization is disabled on Jellyfin 12 (#174, #180).GET /TwoFactorAuth/Users/{id}/Summary and pointed the admin Users details panel at it, so expanding a row no longer hits the step-up-gated export and fails with "Failed to load details"; the per-user Export button now prompts for step-up instead of failing silently (#156).sync-xhr Permissions-Policy violation is attributed to its real caller, and documented an obsolete-feature-free Permissions-Policy header (#149).prompt=login for IdPs such as Authentik that reject forced re-authentication (#119)./.well-known/openid-configuration (#120).TwoFactorEnforcementMiddleware and TwoFactorAuthProvider now add recovery to the challenge's method list whenever the user has unused recovery codes, not only during an emergency lockout (ForceRecoveryOnNextLogin). The Recovery tab in the challenge UI is therefore reachable in the normal verify flow, matching the login portal (which always offered "Use a recovery code instead").UserTwoFactorData record, including orphaned records left behind by deleted accounts, which capped the score; it now counts live Jellyfin users via the ABI-safe EnumerateUsers() shim.data-i18n-html attribute (innerHTML) so the SSO redirect-URI hint renders its <code> snippet instead of literal markup, in all 8 languages. 266/266 tests pass. In-place upgrade. (Shipped 2026-07-03.){server}/{topic} (topic as a path segment, not a header).email_verified surfaced as the C# string "True", so the exact == "true" check read verified emails as unverified and skipped account matching; now compared case-insensitively.realm_access.roles / resource_access.{client}.roles rather than a flat claim; these are now read from the id_token and /userinfo to drive "Allowed groups", "Admin groups", and role→library mapping. 266/266 tests pass. In-place upgrade. (Shipped 2026-07-03.)DefaultAuthenticationProvider, where the app-password check (which lives in the plugin's provider) never runs, so the submitted app password was validated against the real password and rejected. Creating an app password now reassigns the user's AuthenticationProviderId to the plugin provider (same as the passkey / OIDC paths). Re-create any existing app password after updating./0 and out-of-range prefixes rejected.sub_filter on the head-closing tag (common for rebranding "Jellyfin" in the browser tab) was also matching that tag where it appeared inside one of the Setup page's own inline-JS strings (the recovery-codes print template), injecting a script-closing tag mid-script — which dumped the rest of the page as raw text and left "Linked Sign-In Methods" stuck on "Loading…". The print template now builds every structural tag in split pieces, so no closing head/title/style/script tag appears as a literal substring for a proxy sub_filter to latch onto. 266/266 tests pass. In-place upgrade. (Shipped 2026-06-23.)UpdatePolicyAsync (the same path the dashboard uses).Oidc/LinkBegin → popup → link by sub) instead of the normal sign-in, which routes through the resolver that deliberately refuses implicit admin links. Refuses if the identity is already linked to a different Jellyfin user.AdminGroups is now consumed, behind a new opt-in "Elevate matching users to administrator" toggle (default off). Grant-only (never auto-revokes); every elevation logged at WARN. Off-by-default, so a default install is unchanged.AuthenticateByName fails (commonly an auth proxy intercepting it), the bridge page shows the real error + an auth-proxy hint + a manual link instead of an endless login loop.StepUpLevel is set to AllConfigChanges or above, clicking Save in the admin UI used to silently fail with no UI prompt because the main config save call went through Jellyfin's built-in ApiClient helper, which doesn't know about the plugin's stepUpRequired response. Re-wired through the existing step-up-aware fetch wrapper so the same TOTP modal that gates every other admin action now also gates plugin-config saves.StepUpLevel dropdown persists across saves — enum-serialization mismatch was making the dropdown go blank after every save, and silently posting 0 (Off) which reset the level on the server. The dropdown's <option value=> strings now match Jellyfin's JsonStringEnumConverter wire format.10.0.0.0/8) into Trusted Proxy CIDRs caused LAN bypass to silently refuse for every LAN client (the SEC-H3 guard from v2.4.12 can't distinguish "stale-XFF proxy" from "direct LAN client in a broad range"). Added a help block under the admin field spelling out the trap, and promoted the SEC-H3 refusal log to Information level on first hit per peer IP so admins see the actionable diagnostic in their logs without filtering for Debug.alert + console.error instead of leaving the button looking dead. Touches the disabled-during-request UX too..tfa-input now uses box-sizing: border-box so width:100% textareas (LAN CIDRs, Trusted Proxy CIDRs, Admin emails, Exempt CIDRs, Restore JSON) sit inside their parent panels instead of bleeding the horizontal padding outside.cs/cleartext-storage-of-sensitive-information on the SEC-H3 log line was flagging CIDR strings as if they were credentials. CIDRs are admin-configured network topology, already in cleartext in PluginConfiguration.xml by necessity, and the SEC-H3 diagnostic depends on surfacing the matched CIDR.SelfServiceStepUpMode=Forced by re-authenticating to that IdP in a popup. Subject match against the stored SsoLink is enforced, so signing into a different IdP account doesn't grant step-up./Users/Foo getting 403'd by RequestBlockerMiddleware after docker compose down/up is gone. The plugin now persists SHA-256 hashes of verified tokens to a sidecar JSON, so the in-memory verified-set is rehydrated on every restart instead of locking out every active session.Guid.Empty lockout entries from brute-force testing no longer 500 the Users tab. Two layers: defensive skip in the listing + write-refuse at the store boundary.login.html was missed. Thanks to @duongynhi000005-oss for the PR.console.error, instead of silently looking dead.TreatWarningsAsErrors=true. In-place upgrade — every persisted record (TOTP, passkeys, OIDC links, trusted browsers, paired devices, audit history) carries over. (Shipped 2026-06-05.)BareDeviceIdBypassEnabled flag (default off). The signed trusted-device cookie path is unchanged.ForceHttps=true; existing providers also get https automatically.inject.js and Jellyfin's bundled scripts fixed.SelfServiceStepUpMode, default Forced) — adding/replacing TOTP, recovery codes, app password, or passkey now requires proof of an existing factor.BlockEmptyPasswordLogin (default off) — when true, the plugin refuses empty/whitespace passwords for all users.MarkTokenVerified so the 30-day verified flag short-circuits later SessionStarted re-evaluations regardless of proxy IP rotation.IUserManager.Users property → GetUsers() method rename handled via reflection so the same DLL still loads on 10.11.0–10.11.9.Hardening
Off / Destructive / AllConfigChanges / Everything) re-prompts the admin for 2FA before sensitive operations. Step-up tokens are single-use.RequireTwoFactorToDisable flag — re-prompts for 2FA before a user can disable their own 2FA.Observability
/Dashboard/Overview endpoint — accepts ?range=1w|1m|1y. Backs the chart and the score breakdown.Internationalization
tfa-i18n.js shared helper — tr() / loadTranslations() / applyTranslations() / renderLanguagePicker() / getEffectiveLanguage() + a ready promise so dynamic JS-rendered content waits for the bundle.?lang= → per-user pref → localStorage → server DefaultLanguage → English./TwoFactorAuth/public-config — exposes default language to anonymous pages./TwoFactorAuth/translations/{lang} — embedded-resource endpoint with strong caching.Indefinite device trust (opt-in)
AllowIndefiniteTrust config flag — default off. When off the user-side toggle is hidden entirely.IndefiniteTrust=true.Other fixes
admin-script.js externalized from admin.html so Jellyfin's SPA loadView template-literal stripping no longer breaks the dashboard with a SyntaxError: Unexpected token 'class'./Dashboard/Overview DTOs flattened to force camelCase JSON serialization./Users/Me Id into a module-scope _myUserId so the indefinite-trust toggle works without window.ApiClient (which isn't loaded on the Setup page)._userManager.Users.HasPermission(PermissionKind.IsAdministrator) directly (typed extension method) — fixes the 0/0 admin count regression.window.tfaI18n.ready so it doesn't render in English before the translation bundle loads.Tests: 254/254 pass. Clean build with TreatWarningsAsErrors=true.
Upgrade: in-place — existing TOTP enrollments, passkeys, OIDC links, trusted browsers, paired devices, and audit history all carry over.
Security
Fixes
Require 2FA for all users now has a proper forced-enrollment flow for users who do not have 2FA set up yet.Fixes
Fonts.SegoeUI to "Lato" thinking QuestPDF auto-loaded Lato — it doesn't, the constant is just a name. Skia's fallback found nothing usable inside the Jellyfin Docker container (no system fonts) and rendered every glyph as an empty box.FontManager in the RecoveryCodePdfService static constructor. Works on any container regardless of installed system fonts.Fixes
Fonts.SegoeUI / Fonts.Consolas (Windows-only fonts), which produced a PDF full of empty glyph boxes when generated on a Linux host. Switched to the cross-platform Lato font that QuestPDF bundles by default.UI
confirm() popup. Esc cancels, Enter confirms, click outside cancels.New
linux-x64 shipped working native libs, so Pi / Apple-Silicon-Linux / Alpine deployments couldn't generate the recovery PDF.Architecture.X64 / Arm64 + /lib/ld-musl-* sniff) in RecoveryCodePdfService picks the right RID's natives at startup, copies them next to the plugin DLL where QuestPDF probes, and NativeLibrary.Loads them in dependency order before the first render.InvalidOperationException instead of taking the whole plugin down.Build
build.sh rewritten as a fat-package builder: managed assemblies published once without RID, then per-RID native libs (linux-x64, linux-arm64, linux-musl-x64) bundled into runtimes/<rid>/native/ with a copy at the plugin root..github/workflows/build-multiarch.yml runs the fat build inside a mcr.microsoft.com/dotnet/sdk:9.0 Docker container and publishes the zip + MD5 + SHA256 to a GitHub Release.Credit
v2.1.0.1 in their fork). Thanks Glaucio.Hardening
Secure flag when Jellyfin sits behind a TLS-terminating proxy (Cloudflare, Caddy, nginx, Traefik). Enable by setting TrustForwardedFor + TrustedProxyCidrs in plugin settings.Performance
/web/ index. Disk I/O on every login is now near-zero.No breaking changes. In-place upgrade — existing TOTP enrollments, passkeys, OIDC links, trusted browsers, paired devices, and audit history all carry over.
New
POST /TwoFactorAuth/Passkey/LoginBegin + POST /TwoFactorAuth/Passkey/LoginComplete (anonymous, rate-limited 20/5min per IP).Fix
inject.js now served with Cache-Control: no-store so CDN / reverse-proxy caching doesn't pin old script after plugin upgrades. If you hit this on v2.0 (Cloudflare 24h default), just upgrade — new buttons and hardening now appear immediately without a manual purge.Note: WebAuthn requires a secure context (HTTPS, or plain localhost). The passkey button is hidden when accessing Jellyfin over plain-HTTP LAN IPs — that's a browser rule, not a plugin limit.
Plugin rename from "Two-Factor Authentication" to "Jellyfin Security" (GUID unchanged — upgrades in place). The plugin now spans the whole auth + hardening stack.
New features
AuthenticationProviderId on first link so bridge tokens authenticate correctly.Security hardening
X-Forwarded-Host / Proto only honoured when direct peer is in TrustedProxyCidrs (prevents redirect_uri poisoning)./Oidc/Login (20 per 5 min per IP).JsonSerializer.Serialize for JS context injection + strict CSP + Cache-Control: no-store.returnUrl on sign-in validated to same-origin relative paths./TwoFactorAuth/MyStatus (auth-only) so the user Setup page shows correct TOTP state without admin permission.Bug fixes
/Users endpoint)./web/ corruptionCritical fix for anyone upgrading to 1.4.x. The IndexHtml injection middleware (which inserts <script src="/TwoFactorAuth/inject.js"> into Jellyfin's main index page) was reading the response buffer as UTF-8 text without checking Content-Encoding. When Jellyfin served the pre-gzipped index.html.gz static asset, the middleware read compressed bytes as text, mangled them, and wrote garbage back — the browser then tried to render the binary gzip payload as text, producing a wall of mojibake and the entire web UI refusing to load.
Fix: strip Accept-Encoding from the incoming /web/ request before the response is generated, so Kestrel's static-file handler responds with identity-encoded HTML we can safely inject into. Only applied to the three specific paths the middleware intercepts (/web/, /web, /web/index.html) — other assets still compress normally. Cost: one uncompressed ~50KB HTML per page load. Negligible.
If you're on 1.4.0 or 1.4.1 and the web UI renders as random characters, upgrade.
Critical regression fix. Samsung Tizen (Smart TV) clients behind any reverse proxy (Caddy, nginx, Cloudflare Tunnel, etc) couldn't sign in after upgrading to v1.4 — password entry returned "Invalid username or password" immediately. Root cause: the TV's AuthenticateByName request arrives at the server without an X-Emby-Device-Id header and with a reformatted X-Emby-Authorization that the plugin's parser couldn't extract a deviceId from. No deviceId meant paired-device and registered-device bypasses silently skipped, and the middleware rewrote the auth response as a 2FA challenge — which the native Tizen app can't render, so it just looped on "Invalid".
Fixes:
SessionInfo.DeviceId from Jellyfin's auth response body as a fallback when request headers don't carry a deviceId. That value is always present and authoritative.RegisteredDeviceIds bypass lookup now uses the same UA-hash normalisation as PairedDevices so Tizen webview deviceIds (which include a per-session timestamp suffix that changes on every app restart) match across restarts.If you're on Tizen / Jellyfin for Smart TV and couldn't sign in after v1.4, this release fixes it. No re-pair needed.
New factors
User self-service
autocomplete="one-time-code" on the OTP input — iOS picks codes from Messages.Admin tools
{event, user, ip, timestamp, payload} to any URL. Optional HMAC-SHA256 signature header (X-2FA-Signature: sha256=...) computed over <unix-timestamp>.<body>. The unix timestamp is also exposed as X-2FA-Timestamp so receivers can do replay/skew checks without parsing the JSON body. Events: lockout, new device, recovery used, suspicious login, passkey registered, TOTP rotated, emergency lockout, admin force-logout.Security & integrity
audit.json is detectable. The Diagnostics tab verifies the chain on demand.Tunables
New dependencies bundled (Linux x64 native libs included; Windows / macOS users currently need Docker or to manually supply libsodium):
Critical fixes
deviceId and expiry into the payload. A stolen cookie can no longer be replayed with an attacker-chosen X-Emby-Device-Id header (device substitution bypass). Cookie rotates on every use.(userId, deviceId, token) and single-consume — closes a narrow timing window that could leak a bypass./TwoFactorAuth/Challenge?return= closed — same-origin check with javascript: / data: / file: rejection.High-severity fixes
PairedDevice / TrustedDevice deviceId comparisons are now case-sensitive (Ordinal). Previously OrdinalIgnoreCase allowed case-variant bypass.Guid.Empty user or empty deviceId (phantom-user write prevention).RegisteredDeviceIds capped at 50 per user with 128-char printable-ASCII validation — no more storage-inflation DoS.IsAuthPath is now anchored to ^/Users/… instead of substring Contains — closes a confused-deputy path where a third-party plugin's response could be rewritten as a 2FA challenge.X-Frame-Options: DENY, CSP frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer on all embedded pages (anti-clickjacking).TrustForwardedFor + TrustedProxyCidrs. IPv6 is bucketed by /64 to prevent host-rotation bypass./Verify now has a per-user rate limit (15 per 15 min) in addition to per-IP./Pairings/Initiate input (Username, DeviceName) sanitized against control characters and HTML-significant bytes; length-capped at 64.Medium-severity fixes
inject.js redirects to a hardcoded /TwoFactorAuth/Challenge?token=… path instead of trusting the server body's ChallengePageUrl.TestSmtp admin endpoint no longer echoes ex.Message — full detail goes to server logs.Logout(accessToken) on any live session for that device.PairConfirm records a short-TTL seen-signature set — the same signed pairing token can only be used once.CookieSigner.Verify length-checks signatures before FixedTimeEquals to eliminate the throw/non-throw timing oracle.Quality of life
deviceId and clears stale pending pairings for the same device — browsers that alternate between LAN and Cloudflare (NAT hairpin) no longer accumulate pending entries.IAuthenticationProvider (TwoFactorAuthProvider now resolves IUserManager lazily via IApplicationHost).Contributors who have shipped substantive changes to this plugin:
v2.1.0.1 in their fork. Pi / Apple-Silicon-Linux / Alpine deployments work because of this.Maintained by @ZL154. PRs and issue reports welcome.
2FA for Jellyfin is built and maintained in my spare time. If it's protecting your server and you'd like to support ongoing development, any of these means a lot:
Not expected, just appreciated. Security issues reported responsibly are equally valuable.
MIT — see LICENSE.
| You can | You must | You cannot |
|---|---|---|
| Use on any server, personal or commercial | Keep the copyright notice in any redistribution | Hold the authors liable for damage |
| Fork and modify | Claim author endorsement of your fork | |
| Redistribute, modified or unmodified |
⭐ If you use this plugin, consider starring the repository.
C#
69.2%
HTML
16.0%
JavaScript
13.6%