πŸ” Jellyfin Security

May 31, 2026 Β· View on GitHub

Jellyfin Security

β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
β•šβ•β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—
 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘
β–ˆβ–ˆβ•”β•β•β•β• β–ˆβ–ˆβ•”β•β•β•  β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘
β•šβ•β•β•β•β•β•β•β•šβ•β•     β•šβ•β•  β•šβ•β•

CI CodeQL OpenSSF Scorecard Tests Security policy

Stars Last commit Open security advisories

πŸ” Jellyfin Security

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.


πŸ›‘οΈ Security posture β€” what to check before you trust this with your server

You don't have to take my word for it. Every signal below is automated and visible to anyone, including you:

  • CI badge β€” every push and PR builds and runs the full xUnit test suite (254 tests covering crypto, parsers, and middleware). Green = tests pass.
  • CodeQL badge β€” GitHub's static security scanner runs the security-extended + security-and-quality C# query packs on every push, PR, and weekly. Green = no security findings.
  • OpenSSF Scorecard β€” the Linux Foundation's automated security-posture rating (0–10). Scores branch protection, CodeQL, dependency updates, pinned actions, signed releases, security policy, token permissions, and more. Click the badge to see the per-check breakdown.
  • Test suite β€” 254 xUnit tests covering the security-critical code paths (cookie HMAC, TOTP replay protection, recovery-code PBKDF2, CIDR parser, X-Forwarded-For trust-walk, refuse-LAN-bypass-when-XFF-missing guard, device-token binding, AES-GCM v2 AAD, HIBP k-anonymity hashing, atomic challenge consumption, OIDC redirect_uri proxy-header resolution, OIDC userinfo claim merge, SMTP port 465 socket-option mapping, step-up code verification, step-up action classification, ChallengeStore step-up tokens). Runs on every PR + push.
  • Open security advisories β€” historical vulnerabilities filed via SECURITY.md, with patch versions, severity, and CVE references.
  • Dependabot PRs β€” security and version updates for every NuGet dependency. Frequent merges = vulnerabilities don't sit unpatched.
  • Pull request review history β€” non-trivial changes go through review even when the maintainer is solo, and the diff is public.
  • Release SHA-256 checksums β€” every release ships with .md5 and .sha256 files alongside the .zip so you can verify the artifact wasn't tampered with after upload.
  • Threat model in SECURITY.md β€” explicit list of what the plugin defends against and what it intentionally does not. No hand-waving "secure by design" claims.

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.


πŸ†• What's new in v2.5.0

  • Step-up authentication β€” destructive admin actions (disable plugin, delete user, rebuild audit chain) now re-prompt for 2FA. Four levels: Off, Destructive, AllConfigChanges, Everything.
  • Encrypted configuration exports β€” back up plugin config with a passphrase. AES-256-GCM, PBKDF2-SHA256 600k iterations, versioned envelope.
  • Admin Dashboard Overview β€” auth-activity time series with 1w / 1m / 1y range selector, hover tooltips, and 12-factor security score (expanded from 5).
  • Internationalization β€” 8 languages (English, Deutsch, EspaΓ±ol, FranΓ§ais, Italiano, ζ—₯本θͺž, PortuguΓͺs, δΈ­ζ–‡) at full key parity. Native-name picker. Per-user preference + server-wide default.
  • Indefinite device trust β€” admin-gated opt-in for users who want a device signed in until they explicitly log out. Off by default.
  • Audit chain rebuild β€” admin action (step-up gated) to repair a broken audit-log hash chain after disk corruption or manual edits.
  • RequireTwoFactorToDisable hardening flag β€” disabling 2FA self-service now requires a fresh 2FA challenge.
  • Translatable score factor labels β€” every score factor shows in the user's chosen language.
  • 254/254 tests pass. Clean build with TreatWarningsAsErrors=true. In-place upgrade β€” existing TOTP enrollments, passkeys, OIDC links, trusted browsers, paired devices, and audit history all carry over.

πŸ“‘ Table of contents


⚑ How it works

  1. Each user opts into 2FA via /TwoFactorAuth/Setup β€” scans a QR code with an authenticator app and saves recovery codes.
  2. On normal login, Jellyfin's SessionStarted event fires. The plugin checks if the user has 2FA enabled.
  3. If yes, the plugin blocks all subsequent API requests from that session until the user completes 2FA via /TwoFactorAuth/Login.
  4. After successful verification, a signed __2fa_trust cookie is set in the browser. For 30 days, that browser doesn't need 2FA again β€” but new browsers/devices still do.
  5. The block applies regardless of how the user authenticated (Jellyfin web, mobile API, anything that creates a session).

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.


🧩 Features

New in v2.5

  • Step-up authentication β€” configurable level (Off / Destructive / AllConfigChanges / Everything) re-prompts for 2FA on sensitive admin actions.
  • Encrypted configuration exports β€” passphrase-protected (AES-256-GCM, PBKDF2-SHA256 600k iter) versioned export envelopes for back-up / migration.
  • 12-factor security score β€” coverage, admins, enforcement, audit chain, IP ban, impossible-travel (functional check), HIBP, clean-7d audit, require-to-disable, step-up, webhook, recovery codes. Raw 130 pts normalized to a 100 ceiling.
  • Admin Dashboard Overview β€” auth-activity stacked-area chart with 1w / 1m / 1y range selector, hover tooltips, dashed gridlines, x-axis date markers, server-side bucket backfill.
  • Internationalization (8 languages) β€” en / de / es / fr / it / ja / pt / zh at full key parity. Native-name picker. Per-user preference + server-wide default + URL ?lang= override.
  • Indefinite device trust (opt-in) β€” admin-gated AllowIndefiniteTrust flag; users can mark individual trusted browsers / paired devices to never expire.
  • Audit-chain rebuild β€” admin action (step-up gated) to repair audit-log hash continuity after disk corruption.
  • RequireTwoFactorToDisable β€” re-prompts for 2FA before a user can disable their own 2FA.

New in v2.0

  • OIDC / SSO sign-in β€” Google, Microsoft/Entra, Apple, Authelia, Authentik, Keycloak, PocketID, Cloudflare Access, or any OIDC-compliant IdP. PKCE, id_token signature validation, group-based authorisation, optional AMR-based IdP-MFA enforcement.
  • Brute-force IP banning β€” auto-bans source IPs that exceed N failed sign-ins in M minutes. Persisted across restarts, with admin UI to list/unban.
  • Impossible-travel detection β€” notifies when consecutive sign-ins exceed commercial-jet cruise speed (β‰ˆ900km/h default). Uses MaxMind GeoLite2-City for lat/lon.
  • Per-user IP allowlist β€” pin high-value accounts (e.g. admin) to specific CIDRs so sign-in is refused from everywhere else.
  • Login-page provider buttons β€” each configured SSO provider shows below the normal sign-in form.
  • Linked sign-in methods in user Setup β€” users see/unlink their external accounts self-service.

Authentication

  • TOTP (RFC 6238) compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator, Bitwarden, etc.
  • 10 single-use recovery codes generated at enrollment, stored as SHA-256 hashes, displayable once
  • Email OTP fallback via configurable SMTP β€” codes expire in 5 minutes, single-use
  • Per-device trust via signed HTTP-only cookie (HMAC-SHA256, 30-day expiry, SameSite=Strict)

Enforcement

  • Session-level enforcement via ISessionManager.SessionStarted β€” works for all clients, not just web
  • API-level request blocking β€” even valid Jellyfin tokens get 401 until 2FA is completed
  • Per-IP rate limiting on verify (10/min) and email send (5/5min)
  • Per-challenge attempt limit (5 attempts before challenge is burned)
  • Per-user lockout after 5 failed attempts (15-minute cool-down, configurable)
  • LAN bypass (configurable CIDR ranges) so local devices can skip 2FA
  • Force-2FA-for-all-users mode (admin setting)

Security

  • TOTP secrets encrypted at rest with AES-GCM using a persistent 32-byte key (survives restarts)
  • Cookie signatures use HMAC-SHA256 with persistent key
  • Constant-time comparison for all secret material (CryptographicOperations.FixedTimeEquals)
  • TOTP replay prevention (used time-steps tracked per user)
  • Recovery codes marked used immediately on validation (not on full login success) β€” stolen codes can't be retried
  • Atomic file writes for user data β€” crash mid-write doesn't corrupt 2FA state
  • Generic error messages prevent account enumeration ("invalid credentials" whether password or code is wrong)

Native client support (v1.3.0)

  • App passwords β€” generate revocable long random passwords for native apps (Swiftfin, Findroid, etc.). Stored as PBKDF2-SHA256 hashes. Users with a Jellyfin password can enter the app password in the native client's password field to bypass 2FA.
  • Device pairing β€” passwordless users (no Jellyfin password) can pair native clients: the first failed login registers a "pending pairing request." The user approves it from /TwoFactorAuth/Setup, and the device is permanently trusted.
  • Quick Connect pass-through β€” when a 2FA-verified user approves a Quick Connect code, the new device inherits the verified status. TVs sign in without a TOTP prompt.
  • Active sessions view β€” users can see all their active sessions with device/IP/last-activity and sign them out individually.

UI

  • Polished login page with lockout countdown and low-recovery-code warning
  • Redesigned Setup page with status dashboard, TOTP enrollment, recovery codes, email backup, pending device approvals, paired devices, app passwords, trusted browsers, and active sessions β€” all in one unified view
  • Admin dashboard with users, devices, audit log (paginated, filterable), and settings with Test SMTP button
  • Configurable TOTP issuer name (what users see in their authenticator app)
  • Per-user email address management (self-service from Setup page or admin-set)
  • "Sign in with 2FA" button auto-injected into Jellyfin's standard login page
  • "Two-Factor Auth" sidebar entry injected into Jellyfin's navigation drawer (follows AchievementBadges' proven DOM injection pattern)
  • Settings page tile so users can find Setup from their preferences

Notifications

  • Push notifications for login attempts via ntfy or Gotify
  • Audit log of every 2FA-related event (1000 entries default, FIFO, 90-day prune)

βš™οΈ Installation

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 targetAbi is newer than the server. Check your version under Dashboard β†’ About; upgrade to 10.11+ if needed.

  1. Open Jellyfin β†’ Dashboard β†’ Plugins β†’ Repositories
  2. Click + and add this URL:
https://raw.githubusercontent.com/ZL154/JellyfinSecurity/main/manifest.json
  1. Save and refresh plugins
  2. Go to the Catalogue tab β†’ install Jellyfin Security
  3. Restart Jellyfin

Build from source

# Windows
.\build.ps1 -Install
# Linux/macOS
chmod +x build.sh && ./build.sh --install

Manual install

Copy these 4 files into <jellyfin-data>/plugins/TwoFactorAuth/:

TwoFactorAuth/
β”œβ”€β”€ meta.json
β”œβ”€β”€ Jellyfin.Plugin.TwoFactorAuth.dll
β”œβ”€β”€ Otp.NET.dll
└── QRCoder.dll

Plugin directories by OS:

  • Docker: /config/plugins/TwoFactorAuth/
  • Linux: ~/.local/share/jellyfin/plugins/TwoFactorAuth/
  • Windows: %LOCALAPPDATA%\jellyfin\plugins\TwoFactorAuth\

Restart Jellyfin after copying.


πŸš€ First-time setup

As an admin

  1. Install the plugin from the manifest URL in Dashboard β†’ Plugins β†’ Repositories β†’ Add, then install Two-Factor Authentication from the catalog and restart Jellyfin.
  2. Go to Dashboard β†’ Plugins β†’ Two-Factor Authentication
  3. Open the Settings tab and verify:
    • βœ… Enabled β€” master switch
    • βœ… Require for all users β€” off by default. When on, every user with a password must enroll (existing trusted sessions keep working). When off, 2FA is opt-in per user.
    • βœ… LAN Bypass β€” skip 2FA when the request comes from a LAN IP (192.168/16, 10/8, 172.16/12 by default). Adds convenience, reduces prompts on local devices.
    • Email OTP β€” optional fallback if a user loses their authenticator. Requires SMTP config below.
  4. If you're behind a reverse proxy (Cloudflare, nginx, Caddy, Traefik):
    • Enable Trust X-Forwarded-For
    • Add your proxy IPs (or Cloudflare's IP ranges) to Trusted Proxy CIDRs
    • Without this, rate limiting collapses to a single bucket because every request looks like it comes from the proxy's loopback.
  5. Optional: configure Notifications (Gotify, ntfy, or webhook) to get alerts when someone triggers a 2FA prompt.

As a user (enroll in 2FA)

  1. Sign in to Jellyfin normally (no 2FA yet)
  2. Open Profile β†’ Two-Factor Authentication (or visit https://your-jellyfin/TwoFactorAuth/Setup)
  3. Click Set up Authenticator App
  4. Scan the QR code with your authenticator (Google Authenticator, Authy, 1Password, Bitwarden, etc.)
  5. Enter the 6-digit code shown in the app to confirm
  6. Generate recovery codes β€” you get 10 single-use codes. Save them in your password manager. Each one can sign you in if you lose your phone.
  7. (Optional) Add your email under Email OTP if you want email as a backup factor.

Signing in with 2FA on the web

From this point, every login from a new browser prompts for a code:

  1. Sign in at /web with username + password as usual
  2. You will be redirected to the 2FA challenge page
  3. Enter the 6-digit code from your authenticator
  4. Done β€” this browser is trusted for 30 days (cookie bound to your device)

Passkeys (v1.4) β€” sign in with Face ID / fingerprint / YubiKey

Passkeys 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:

  • Relying Party ID: enter your public hostname only β€” jellyfin.example.com. No https://, no port, no path.
  • Allowed origins: one per line, full origin including scheme and port β€” e.g. 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).

Add a passkey on a desktop browser

  1. Open the Setup page on the URL you configured above
  2. Setup β†’ Passkeys card β†’ optionally type a label β†’ Add a passkey
  3. Browser prompts your platform authenticator (Windows Hello / Touch ID / a YubiKey USB key)
  4. Tap / scan / confirm β€” the passkey is saved

Add a passkey on iPhone (Safari)

  1. Open Safari and visit your Jellyfin HTTPS URL β€” must be the URL configured as the WebAuthn origin, not the bare LAN IP
  2. Sign in with username + password + 2FA code
  3. Setup β†’ Passkeys β†’ label it (e.g. "iPhone") β†’ Add a passkey
  4. iOS shows "Save passkey for ...?" β€” confirm with Face ID / Touch ID
  5. The passkey is saved to iCloud Keychain and syncs to every Apple device on the same Apple ID

Add a passkey on Android (Chrome)

  1. Open Chrome on Android and visit your Jellyfin HTTPS URL
  2. Sign in with username + password + 2FA code
  3. Setup β†’ Passkeys β†’ label it (e.g. "Pixel 8") β†’ Add a passkey
  4. Android shows "Save passkey to Google Password Manager?" β€” confirm with fingerprint / face unlock
  5. The passkey now lives in your Google account and syncs to every Android signed in with the same Google account

Common Android gotchas:

  • "Add a passkey" does nothing β†’ your phone needs a screen lock (PIN/pattern/biometric). Android refuses to create passkeys without one.
  • "No passkey provider available" β†’ Settings β†’ Passwords & accounts β†’ Passwords β†’ enable Google Password Manager, or set Bitwarden / 1Password as your default credential provider.
  • Samsung Internet sometimes hides the passkey button β€” use Chrome instead.

Using a passkey to sign in

  1. Visit your Jellyfin URL β†’ enter username + password as usual
  2. At the 2FA challenge page β†’ tap πŸ”‘ Use a passkey instead
  3. The browser prompts your authenticator β†’ confirm with biometric / hardware key
  4. You're in. No code typed.

What passkeys do NOT do

  • Native apps (Findroid, Streamyfin, Swiftfin, official Jellyfin app) cannot use passkeys. WebAuthn is a browser-only API; native apps have no hook to call it. For app sign-in use device pairing (below) and the app's own biometric lock (Findroid β†’ Settings β†’ Biometric authentication, Swiftfin β†’ Settings β†’ Security β†’ Lock with Face ID, etc.).
  • Passkeys do not replace your password β€” they replace the 2FA code step. You still enter username + password first.

Native apps / TVs (Jellyfin for Tizen, Swiftfin, Jellyfin Android, etc.)

Native apps don't know how to do a 2FA flow, so the plugin uses device pairing instead:

  1. Open the native app and sign in with your username + password
  2. The app will show "Invalid" or fail to load β€” that's expected. The server recorded a pending pairing for this device.
  3. On any already-trusted device (your laptop, phone browser), go to Setup β†’ Devices Waiting for Approval
  4. You'll see the TV/app listed. Click Trust.
  5. Back on the TV/app, retry sign-in β€” it now works and is remembered permanently.

This way a TV/console/media-box that can't type a TOTP code still gets its own credential you can revoke later.

Native apps that can't do the pairing flow (scripts, older tools)

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.


πŸ”„ Daily use

Web login (browser)

  • On the standard Jellyfin login page, click the πŸ” Sign in with Two-Factor Authentication button
  • Enter your username, password, and 6-digit code from your app
  • After first sign-in on this browser, you won't be asked for the code again for 30 days

Mobile / TV apps (Swiftfin, Findroid, Jellyfin for Tizen, Android TV, etc.)

Use the device pairing flow described in First-time setup:

  1. Sign in on the TV/mobile app with your password
  2. It'll fail once β€” that's normal, the server recorded a pending pairing
  3. Approve the device from Setup on any already-trusted browser
  4. Retry on the TV/app β€” it now works permanently

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.

Sonarr / Radarr / Overseerr / Jellyseerr

Use Jellyfin's standard API keys (Dashboard β†’ API Keys). API key auth bypasses user authentication entirely, so 2FA doesn't apply.


πŸ› οΈ Admin guide

The admin dashboard at Dashboard β†’ Plugins β†’ Two-Factor Authentication has 5 tabs:

Users

Per-user 2FA status: TOTP on/off, trusted device count, recovery codes remaining, email address (for OTP), lockout status.

  • Set per-user email β€” for email OTP delivery (admin sets these manually)
  • Toggle 2FA on/off β€” disabling wipes all 2FA state for that user (secret, codes, devices)

Trusted Devices

Every trusted device across all users with last-used time and expiry. Revoke any to force 2FA on that browser's next login.

Pairings

Pending TV pairing requests (currently a stub β€” see "Limitations" below).

Audit Log

Paginated, filterable login attempt history. Tracks success, failures, lockouts, bypasses, and challenge issuances.

Settings

  • General β€” plugin toggle, force 2FA for all users, email OTP toggle
  • LAN Bypass β€” CIDR ranges, X-Forwarded-For trust, trusted proxies
  • Security β€” failed-attempt threshold, lockout duration, audit log size
  • SMTP β€” host, port, SSL, credentials, from-address (required for email OTP)
  • Push Notifications β€” ntfy URL/topic, Gotify URL/token, admin email addresses
  • Hardening (v2.5) β€” 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)
  • Audit chain (v2.5) β€” Rebuild audit chain button repairs the hash chain after disk corruption / manual edits (step-up gated)

🌐 SSO / OIDC sign-in (v2.0)

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:

  1. Existing SSO link on this Jellyfin user (matched by the IdP's stable sub) β†’ signs in
  2. Email returned by the IdP matches a per-user email configured in the plugin β†’ signs in (and links for next time)
  3. Nothing matched + "Auto-create Jellyfin users" is enabled β†’ a new Jellyfin account is created
  4. Nothing matched + auto-create is OFF β†’ sign-in refused with "No Jellyfin user matched"

Setting up a Google provider (walkthrough)

1. Register a Google OAuth client

  1. Go to Google Cloud Console β†’ create a project (or pick existing)
  2. OAuth consent screen β†’ External β†’ fill App name / support email β†’ add your Gmail as a test user β†’ Finish
  3. Credentials β†’ + Create credentials β†’ OAuth client ID β†’ Web application
  4. Authorised redirect URIs β†’ add exactly: https://YOUR-JELLYFIN-HOSTNAME/TwoFactorAuth/Oidc/Callback/google
  5. Save. Copy the Client ID + Client secret from the dialog.

2. Add the provider in Jellyfin

  1. Jellyfin admin β†’ Plugins β†’ Jellyfin Security β†’ Sign-in Methods tab β†’ "Add provider…"
  2. Preset: Google. Paste Client ID + Secret. Username claim: email. Save.

3. Make sure each Jellyfin user has their Gmail configured

  • Either: each user sets their email on the Setup page (/TwoFactorAuth/Setup), or
  • admin fills it in Jellyfin Security β†’ Users tab's email column (press Tab after typing to save)

4. Done. Sign out and the login page now shows a "Sign in with Google" button. Click β†’ Google consent β†’ bridge page β†’ signed in.

Other providers

PresetDiscovery auto-filledNotes
Googleβœ…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
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

Per-provider options

  • Allowed groups β€” sign-in refused unless IdP's groups / roles claim contains at least one of these
  • Require IdP MFA β€” refuses sign-in unless the id_token's amr claim indicates MFA (mfa, hwk, otp, sca)
  • Auto-create users β€” creates a new Jellyfin account for unmatched IdP identities. Only enable for IdPs where you trust everyone with an account (not public Google).
  • Skip plugin 2FA β€” default ON; the IdP already authenticated. Disable only if you want belt-and-braces.

🚫 Brute-force IP banning (v2.0)

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":

  • Failure threshold (default 10)
  • Window (default 10 min)
  • Ban duration (default 24 h)
  • Exempt CIDRs (never banned β€” e.g. your office IP)

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.


✈️ Impossible-travel detection (v2.0)

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.

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.


πŸ”’ Per-user IP allowlist (v2.0)

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.


πŸ” Step-up authentication (v2.5)

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:

LevelWhat re-prompts
OffNothing. (Default β€” opt in deliberately.)
DestructiveDeleting users, wiping 2FA state, rebuilding the audit chain, removing OIDC providers.
AllConfigChangesAll of Destructive, plus toggling settings, editing SMTP / push / brute-force / impossible-travel config.
EverythingAll of AllConfigChanges, plus viewing audit log, listing IP bans, exporting config. (Strongest β€” least convenient.)

How the flow looks:

  1. Admin clicks a gated action (e.g. Rebuild audit chain).
  2. UI shows a 2FA challenge modal.
  3. Admin enters the 6-digit code (or passkey / recovery code).
  4. Action proceeds. Step-up token is single-use; re-prompts next time.

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.


πŸ“¦ Encrypted configuration exports (v2.5)

Back up or migrate plugin configuration (settings, OIDC providers, trusted CIDRs, brute-force config, etc.) without leaking secrets.

Export (admin):

  1. Admin dashboard β†’ Config β†’ Export.
  2. Enter a passphrase (10+ chars recommended; longer is better).
  3. Download the .json.enc envelope. Treat it like a password β€” its strength is the passphrase's.

Import (admin):

  1. Admin dashboard β†’ Config β†’ Import.
  2. Upload the .json.enc file β†’ enter the same passphrase β†’ review the preview of what will change β†’ confirm.

Crypto envelope (so you can audit it):

  • KDF: PBKDF2-SHA256, 600 000 iterations, 32-byte derived key, 16-byte random salt per export.
  • Cipher: AES-256-GCM with 12-byte random nonce.
  • AAD: Plugin version + envelope version, so an export captured under v2.5 can't be replayed against a future incompatible schema.
  • Versioned envelope: { "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.


πŸ“Š Security score & admin overview (v2.5)

A 12-factor security score (raw 130 points, normalized to 100) and a live auth-activity chart on the admin dashboard.

12 score factors

FactorPointsWhat it checks
Coverage30% of users enrolled in 2FA
Admin coverage20All admins specifically have 2FA on
Enforcement15RequireForAll is on
Audit chain10Hash chain is intact (no breakage)
IP ban8Brute-force banning enabled with sane threshold
Impossible travel7Functional β€” requires GeoIpCityDbPath set to a valid MaxMind file
HIBP5Have-I-Been-Pwned password check enabled
Clean 7-day audit5No failed admin sign-ins in the last 7 days
Require-to-disable8RequireTwoFactorToDisable is on
Step-up7StepUpLevel is Destructive or stronger
Webhook5Push notifications (ntfy / Gotify / webhook) configured
Recovery codes5At least one user has generated recovery codes

Auth-activity overview

Admin dashboard β†’ Overview tab shows a stacked-area chart of successful / failed / blocked sign-ins.

  • Range selector: 1 week / 1 month / 1 year. Buckets are server-side: per-hour for 1w, per-day for 1m, per-month for 1y.
  • Sparse-range backfill: empty buckets are filled with zero on the server so a 1-year chart still spans 12 bars even if only one month has data β€” no collapsed-bar UX.
  • Hover tooltips show exact counts and the bucket date.
  • Dashed gridlines at 25 / 50 / 75 % and first / middle / last x-axis date markers so values are readable without hovering.

🌍 Internationalization (v2.5)

Every user-visible string in the setup, login, challenge, and admin pages is translatable. Ships with 8 first-class languages at full key parity (664 keys each).

LanguageLocaleDisplay name in picker
EnglishenEnglish
DeutschdeDeutsch
EspaΓ±olesEspaΓ±ol
FranΓ§aisfrFranΓ§ais
ItalianoitItaliano
ζ—₯本θͺžjaζ—₯本θͺž
PortuguΓͺsptPortuguΓͺs
δΈ­ζ–‡zhδΈ­ζ–‡

How the active language is chosen (first match wins):

  1. URL ?lang=de override (useful for support / screenshots).
  2. Per-user preference saved from the language picker (PUT /TwoFactorAuth/Users/{id}/preferences).
  3. localStorage (so the picker remembers across sessions).
  4. Server-wide DefaultLanguage setting (admin sets in Settings β†’ Hardening).
  5. Fallback to English.

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):

  • Translation bundles live in src/Jellyfin.Plugin.TwoFactorAuth/Pages/translations/<lang>.json and are served via /TwoFactorAuth/translations/{lang} with strong caching.
  • The shared 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.


πŸ•°οΈ Indefinite device trust (v2.5)

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):

  1. Setup page β†’ Trusted Devices or Paired Devices card.
  2. Click the Indefinite trust toggle on the device you want to never expire.
  3. Confirm. The trust cookie's expiry is set to 100 years and the middleware skips the normal expiry check for this 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.


πŸ“§ SMTP setup (email OTP)

Email OTP requires SMTP credentials. Common providers:

Gmail (with app password)

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

Generic SMTP relay

SMTP Host: mail.example.com
SMTP Port: 587 (STARTTLS) or 465 (implicit TLS)
Use SSL/TLS: βœ“

Per-user email addresses

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).


πŸ†˜ Recovery β€” locked out

Lost authenticator app + have recovery codes

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.

Lost authenticator AND lost recovery codes (admin)

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.


πŸ› οΈ Troubleshooting

Plugin breaking your server

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.

Behind SWAG / fail2ban: other services on the same proxy go offline after a 2FA login

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, you may see this symptom:

  • Jellyfin works fine on the LAN
  • External access via the reverse proxy fails with ERR_CONNECTION_REFUSED
  • Other applications behind the same proxy also become unreachable
  • Brief recovery every ~10–15 minutes, then it fails again

Why 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:

  • If SWAG sees Cloudflare's edge IP (you're behind Cloudflare) β†’ it bans Cloudflare β†’ all external traffic to all services fails
  • If SWAG sees the Docker bridge gateway IP (misconfigured forwarded headers) β†’ inter-container traffic dies β†’ SWAG can't reach any backend
  • If SWAG sees the user's real client IP β†’ only they get locked out

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 serverRecommended 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. A future plugin release may reduce the 401 burst size at the source (tracking issue #36) but the jail-threshold fix above resolves it today.


πŸ—οΈ Architecture

The plugin uses 5 ASP.NET Core middleware components plus an ISessionManager.SessionStarted event handler:

  1. IndexHtmlInjectionMiddleware β€” injects the "Sign in with 2FA" button script into Jellyfin's index.html
  2. TrustCookieMiddleware β€” checks the __2fa_trust cookie on auth requests; if valid, marks the user as pre-verified for the upcoming session
  3. TwoFactorEnforcementMiddleware β€” inspects responses from auth endpoints (catches the auth response shape regardless of which Jellyfin route was used)
  4. RequestBlockerMiddleware β€” blocks API requests from authenticated users who haven't completed 2FA yet (returns 401)
  5. 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

Persistent state:

  • users/{userId}.json β€” per-user TOTP secret (AES-GCM encrypted), recovery codes (SHA-256 hashed), trusted devices, lockout state
  • secret.key β€” 32-byte AES-GCM key for TOTP secret encryption
  • cookie.key β€” 32-byte HMAC-SHA256 key for trust cookie signing
  • audit.json β€” login attempt log

All file writes use atomic write-then-rename so crashes mid-write don't corrupt user state.


πŸ“‘ API endpoints

User-facing (anonymous or self-auth)

GET  /TwoFactorAuth/Login                                β€” login page (HTML)
GET  /TwoFactorAuth/Setup                                β€” enrollment page (HTML)
GET  /TwoFactorAuth/Challenge?token=...                  β€” challenge page (HTML)
GET  /TwoFactorAuth/inject.js                            β€” login button injection
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)

Admin-only (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

πŸ”’ Security model

ThreatMitigation
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 spacePer-IP rate limit (10/min on verify, 10/min on auth), per-challenge attempt limit (5), per-user lockout (5 failures β†’ 15min)
Stolen recovery codeMarked used immediately on validation regardless of password outcome β€” can't be retried
Stolen trust cookieHMAC-SHA256 signed with persistent server-side key; HttpOnly, Secure, SameSite=Strict; tied to a server-side trust record (revocable)
Account enumerationIdentical "invalid credentials" message whether password is wrong, user doesn't exist, or 2FA code is wrong
Disk corruption mid-writeAtomic write-then-rename for all user state files
TOTP secret theft from diskAES-GCM encrypted with persistent 32-byte key
Replay attacks on TOTPUsed time-steps tracked per user
Timing attacksCryptographicOperations.FixedTimeEquals on all secret comparisons
Service integrations breakingStandard Jellyfin API keys bypass user auth β€” Sonarr/Radarr unaffected
Authelia/Authentik breaking native appsNative plugin, no proxy dependency

⚠️ Limitations

  • Mobile apps (Swiftfin, Findroid) β€” these don't support a 2FA flow yet. Workaround: do a 2FA login via web on the same device first; mobile clients can then use the resulting session token. A native mobile flow requires app-side changes.
  • TV pairing flow β€” backend exists, no TV-side UI yet. Use trusted device tokens or admin pre-registration of device IDs.
  • Quick Connect β€” works as Jellyfin's normal flow but creates a session subject to 2FA enforcement (user will be blocked until they complete 2FA via /TwoFactorAuth/Login).
  • Email OTP requires admin to set per-user email β€” Jellyfin's user entity doesn't expose email consistently across versions, so admins enter emails in the Users tab.
  • Cookie isn't bound to IP β€” a stolen trust cookie works from any IP for 30 days, within the signed deviceId. Revoke the device in admin if a browser is compromised.

πŸ“ Changelog

2.5.0 β€” Hardening, observability, i18n, and indefinite trust

Hardening

  • Step-up authentication β€” configurable level (Off / Destructive / AllConfigChanges / Everything) re-prompts the admin for 2FA before sensitive operations. Step-up tokens are single-use.
  • Encrypted configuration exports β€” passphrase-protected backup/migration. AES-256-GCM with PBKDF2-SHA256 (600 000 iterations) key derivation, versioned envelope so future schema changes don't break old exports.
  • Audit-chain rebuild β€” admin action (step-up gated) to repair audit-log hash continuity after disk corruption or manual edits.
  • RequireTwoFactorToDisable flag β€” re-prompts for 2FA before a user can disable their own 2FA.

Observability

  • 12-factor security score (was 5). New factors: clean-7d audit, require-to-disable, step-up coverage, webhook configured, recovery codes generated, impossible-travel as a functional check. Raw 130 pts normalized to a 100 ceiling. Translatable factor labels with interpolation data.
  • Admin Dashboard Overview β€” auth-activity stacked-area chart with 1w / 1m / 1y range selector, hover tooltips, dashed gridlines at 25/50/75 %, x-axis date markers. Server-side bucket backfill so sparse 1-year data still spans the full range.
  • /Dashboard/Overview endpoint β€” accepts ?range=1w|1m|1y. Backs the chart and the score breakdown.

Internationalization

  • 8 languages (en / de / es / fr / it / ja / pt / zh) at full key parity β€” 664 keys each.
  • tfa-i18n.js shared helper β€” tr() / loadTranslations() / applyTranslations() / renderLanguagePicker() / getEffectiveLanguage() + a ready promise so dynamic JS-rendered content waits for the bundle.
  • Native-name picker β€” shows each language in its own script ("Deutsch", "ζ—₯本θͺž", "δΈ­ζ–‡") instead of locale codes.
  • Resolution order: URL ?lang= β†’ per-user pref β†’ localStorage β†’ server DefaultLanguage β†’ English.
  • Admin Settings β†’ Default Language β€” server-wide picker; users can still override per-user.
  • /TwoFactorAuth/public-config β€” exposes default language to anonymous pages.
  • /TwoFactorAuth/translations/{lang} β€” embedded-resource endpoint with strong caching.

Indefinite device trust (opt-in)

  • Admin-gated AllowIndefiniteTrust config flag β€” default off. When off the user-side toggle is hidden entirely.
  • Per-device opt-in for trusted browsers and paired devices. Trust cookie expiry = 100 years; middleware skips expiry check for records flagged IndefiniteTrust=true.
  • Revoke instantly from the same Setup-page card.

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.
  • Setup page stashes /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).
  • Admin enumeration uses _userManager.Users.HasPermission(PermissionKind.IsAdministrator) directly (typed extension method) β€” fixes the 0/0 admin count regression.
  • All dynamic JS-rendered content deferred behind 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.

2.3.0 β€” Security maintenance and forced enrollment

Security

  • Fixed some security issues and tightened the sign-in flow. Details are intentionally kept high-level in public release notes.
  • Strengthened passkey verification requirements and OIDC sign-in handling.

Fixes

  • Require 2FA for all users now has a proper forced-enrollment flow for users who do not have 2FA set up yet.
  • Standard Jellyfin login, plugin login, passkey verification, and Google/OIDC sign-in were tested together so each path keeps the intended 2FA behavior.
  • Fixed a Settings-page layout overlap in the NAT hairpin warning row.

2.2.3 β€” PDF font fix

Fixes

  • Recovery-code PDF now actually renders the text. v2.2.2 swapped the font from 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.
  • Lato-Regular and Lato-Bold are now embedded as resources in the plugin DLL and registered with QuestPDF's FontManager in the RecoveryCodePdfService static constructor. Works on any container regardless of installed system fonts.

2.2.2 β€” UX polish

Fixes

  • Recovery-code PDF now renders correctly inside Linux containers β€” the previous build used 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.
  • 2FA challenge page now exposes a Recovery code tab alongside Authenticator and Email, so a user who lost their authenticator can sign in directly from the challenge screen instead of having to reach the standalone Login page.

UI

  • All Setup-page confirmation prompts (regenerate recovery codes, revoke device / app password / trusted browser, disable 2FA, emergency lockout, etc.) now use a dark-themed in-page modal instead of the browser's native confirm() popup. Esc cancels, Enter confirms, click outside cancels.

2.2.1 β€” Multi-architecture support

New

  • Recovery-code PDF generation (QuestPDF) now works on linux-x64, linux-arm64, and linux-musl-x64 containers. Previously only linux-x64 shipped working native libs, so Pi / Apple-Silicon-Linux / Alpine deployments couldn't generate the recovery PDF.
  • Runtime architecture + libc detection (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.
  • PDF init now wraps in try/catch β€” if native deps fail to load on an unsupported runtime, the rest of the plugin keeps working and PDF render throws a clear 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.
  • New .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

  • Multi-arch QuestPDF runtime fix and fat-package build flow contributed by @glauciocampos (originally cut as v2.1.0.1 in their fork). Thanks Glaucio.

2.2.0 β€” Hardening + performance

Hardening

  • Internal hardening pass on the auth pipeline: cookie attribute handling behind reverse proxies, stricter forwarded-header handling, additional input bounds on auth endpoints, tightened token binding.
  • Trusted-browser cookie now correctly carries the Secure flag when Jellyfin sits behind a TLS-terminating proxy (Cloudflare, Caddy, nginx, Traefik). Enable by setting TrustForwardedFor + TrustedProxyCidrs in plugin settings.

Performance

  • In-memory caches on the hot auth path: per-user data, audit log, parsed CIDRs, and the patched /web/ index. Disk I/O on every login is now near-zero.
  • Login latency improved by replacing an internal polling wait with immediate signaling β€” fewer 50–500ms ticks per successful sign-in.
  • Audit log is now background-flushed instead of rewritten on every event, and stored as compact JSON. Existing logs continue to read fine.

No breaking changes. In-place upgrade β€” existing TOTP enrollments, passkeys, OIDC links, trusted browsers, paired devices, and audit history all carry over.

2.1.0 β€” Passkey primary login

New

  • "Sign in with passkey" button on the standard Jellyfin login page, below the 2FA button. Type username β†’ click β†’ authenticator prompt (Face ID / Touch ID / Windows Hello / YubiKey) β†’ signed in. No password needed, no 2FA challenge layered on top. Uses the same one-shot bridge-token mechanism as OIDC.
  • New endpoints 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.

2.0.0 β€” Jellyfin Security

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

  • OIDC / SSO sign-in β€” Google, Microsoft, Apple, Authelia, Authentik, Keycloak, PocketID, Cloudflare Access, or any OIDC-compliant provider via discovery. PKCE (S256), id_token signature + issuer + audience + nonce validation, optional AMR-based IdP-MFA enforcement, optional group allowlist.
  • Brute-force IP banning β€” threshold + window + duration configurable. LAN / trusted-proxy / exempt CIDRs never banned. Bans persist across restarts. Admin IP Bans tab lists/unbans.
  • Impossible-travel detection β€” Haversine distance vs. time exceeding configured km/h fires a notification via existing channels. Uses GeoLite2-City.
  • Per-user IP allowlist β€” pin high-value accounts to specific CIDRs. Self-service in Setup.
  • Login-page provider buttons β€” anonymous public-providers endpoint; inject.js renders "Sign in with X" below the normal form.
  • OIDC bridge auth β€” server-side one-time bridge tokens wire the OIDC success back into a Jellyfin session without relying on fragment params or the SPA router. Auto-reassigns the user's AuthenticationProviderId on first link so bridge tokens authenticate correctly.
  • Admin UI refresh β€” pill-style tab bar, new Sign-in Methods and IP Bans tabs, new Settings sections for brute-force and impossible-travel.

Security hardening

  • X-Forwarded-Host / Proto only honoured when direct peer is in TrustedProxyCidrs (prevents redirect_uri poisoning).
  • Rate limit on /Oidc/Login (20 per 5 min per IP).
  • Bridge HTML uses JsonSerializer.Serialize for JS context injection + strict CSP + Cache-Control: no-store.
  • returnUrl on sign-in validated to same-origin relative paths.
  • New /TwoFactorAuth/MyStatus (auth-only) so the user Setup page shows correct TOTP state without admin permission.

Bug fixes

  • TOTP replay cache now cleared on new-secret generation β€” fixed "Invalid code" false-positive when Begin Setup ran twice.
  • Setup page no longer silently shows "NOT SET UP" for non-admin users (was calling admin-only /Users endpoint).

1.4.2 β€” Fix gzip-encoded /web/ corruption

Critical 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.

1.4.1 β€” Tizen / reverse-proxy bug fix

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:

  • Enforcement middleware now reads 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.
  • Removed dev-only diagnostic log lines accumulated during the investigation.

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.

1.4.0 β€” Passkeys + safety net

New factors

  • Passkeys / WebAuthn as a 2nd-factor option. Sign in with Face ID, Windows Hello, Touch ID, a YubiKey, or any FIDO2 authenticator. Phishing-resistant (signature is bound to your domain). Add and remove passkeys from Setup β†’ Passkeys. Passkey verification replaces the OTP step at the 2FA challenge β€” username + password still happen first.

User self-service

  • "I lost my phone" emergency lockout β€” single button on Setup. Terminates every session, revokes every trusted/paired device, requires recovery code or email OTP to sign back in.
  • TOTP secret rotation β€” replace your authenticator seed without admin involvement (current code + a recovery code).
  • Recovery codes PDF + print β€” download as PDF or print directly from the browser instead of the .txt download.
  • QR-pair-from-phone β€” Setup page renders a QR an already-signed-in phone can scan to add this browser as a paired device. Reverse direction of the existing TV pairing.
  • autocomplete="one-time-code" on the OTP input β€” iOS picks codes from Messages.

Admin tools

  • Overview / adoption dashboard β€” % enrolled, recent enrollments, failed verifies + lockouts in last 24h, users past the configured enrollment deadline.
  • Diagnostics tab β€” run a green/red checklist (signing keys readable, audit chain intact, IAuthenticationProvider registered, recovery hash format upgrade complete, GeoIP DBs loaded, etc.).
  • Rate-limit observability β€” see when buckets trip, key by key, since last restart.
  • Bulk user actions β€” disable 2FA / rotate recovery / revoke paired / revoke trusted / force logout, applied across N users at once.
  • User search + filter in the Users tab.
  • Force-logout user button per row β€” kills every session, clears trust state.
  • Per-user GDPR export β€” JSON dump of everything we have on file (no secrets).
  • Webhook events β€” POST {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.

    Privacy note: webhook payloads include the username, source IP, device name, and (for suspicious-login events) ASN + country code. Don't send webhooks to a third-party service you wouldn't share that data with. The plugin refuses to dispatch to RFC1918, loopback, link-local (incl. cloud metadata 169.254/16) or IPv6 private/link-local addresses as a basic SSRF guard.
  • Suspicious-login alerts β€” first sign-in from a never-seen ASN/country fires a notification. Requires admin to drop free MaxMind GeoLite2 .mmdb files into the config dir (paths configurable in Settings).

Security & integrity

  • Audit log hash chain β€” each entry's hash chains the previous, so silent tampering with audit.json is detectable. The Diagnostics tab verifies the chain on demand.
  • Per-user concurrent-session cap β€” admin sets a default and per-user override; oldest non-paired sessions get evicted when over the limit.
  • NAT-hairpin self-IP bypass (opt-in) β€” admin can have the plugin auto-discover the server's public IP at startup and treat hairpinned requests as LAN. Documented with an explicit warning about the IoT/guest-WiFi blast radius.

Tunables

  • Pre-verify window (the brief allowance after a successful verify so follow-up sessions go through) β€” configurable 30s–900s.
  • Trust cookie TTL β€” configurable 1d–90d.
  • Optional enrollment deadline β€” flagged on the Overview dashboard.

New dependencies bundled (Linux x64 native libs included; Windows / macOS users currently need Docker or to manually supply libsodium):

  • Fido2NetLib (MIT) β€” FIDO2 / WebAuthn server-side
  • MaxMind.Db (Apache 2.0) β€” offline ASN/country lookup
  • QuestPDF (Community license β€” free under USD 1M revenue) β€” recovery-codes PDF render

1.3.3 β€” Security hardening

Critical fixes

  • Trust cookie now signs the 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.
  • Token-approval race between the SessionStarted event handler and response-intercept middleware is now bound to (userId, deviceId, token) and single-consume β€” closes a narrow timing window that could leak a bypass.
  • Recovery codes upgraded from plain SHA-256 to PBKDF2-SHA256 (100k iters, per-code salt). Legacy codes still validate seamlessly; new generations write the hardened format.
  • Open redirect in /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.
  • Pairing approve refuses records with 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).
  • Rate limiter is now reverse-proxy aware via 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.
  • Device revocation (both paired and trusted) wipes in-memory pre-verified flags and calls 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.
  • API keys are now stored as SHA-256 hash + short preview. Raw key is shown once on create. Legacy plaintext keys auto-migrate on first load; the API key listing never returns the raw secret.
  • CookieSigner.Verify length-checks signatures before FixedTimeEquals to eliminate the throw/non-throw timing oracle.

Quality of life

  • Settings tile now renders inline with Profile/Quick Connect/Display under the user section of themed drawers (JellyFlare, StarTrack, KefinTweaks). Previously appeared in a floating bottom-left position.
  • Dev-only log chatter moved to Debug. Info/Warn retained only for audit-worthy events (challenge issued, bypass applied, lockout, paired device added/revoked).
  • LAN bypass now auto-registers the deviceId and clears stale pending pairings for the same device β€” browsers that alternate between LAN and Cloudflare (NAT hairpin) no longer accumulate pending entries.

1.3.2

  • Fixed DI circular dependency when registering IAuthenticationProvider (TwoFactorAuthProvider now resolves IUserManager lazily via IApplicationHost).
  • Samsung Tizen / Jellyfin for Tizen pairing works end-to-end.
  • Login loop fixed by removing access-token blocking β€” middleware response-intercept is now the only gate.

πŸ™ Credits

Contributors who have shipped substantive changes to this plugin:

  • @glauciocampos β€” multi-architecture QuestPDF runtime fix and fat-package build flow (v2.2.1). Originally cut as 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.


❀ Support the project

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.


πŸ“œ License

MIT β€” see LICENSE.

You canYou mustYou cannot
Use on any server, personal or commercialKeep the copyright notice in any redistributionHold the authors liable for damage
Fork and modifyClaim author endorsement of your fork
Redistribute, modified or unmodified

⭐ If you use this plugin, consider starring the repository.