π Jellyfin Security
May 31, 2026 Β· View on GitHub
βββββββ ββββββββ ββββββ
ββββββββββββββββββββββββ
βββββββββββββ ββββββββ
βββββββ ββββββ ββββββββ
βββββββββββ βββ βββ
βββββββββββ βββ βββ
π 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-qualityC# 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
.md5and.sha256files alongside the.zipso 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.
RequireTwoFactorToDisablehardening 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
- Features
- Installation
- First-time setup
- Daily use
- Admin guide
- SSO / OIDC sign-in (v2.0)
- Brute-force IP banning (v2.0)
- Impossible-travel detection (v2.0)
- Per-user IP allowlist (v2.0)
- Step-up authentication (v2.5)
- Encrypted configuration exports (v2.5)
- Security score & admin overview (v2.5)
- Internationalization (v2.5)
- Indefinite device trust (v2.5)
- SMTP setup (email OTP)
- Recovery β locked out
- Troubleshooting
- Architecture
- API endpoints
- Security model
- Limitations
- Changelog
- Credits
- Support the project
- License
β‘ How it works
- Each user opts into 2FA via
/TwoFactorAuth/Setupβ scans a QR code with an authenticator app and saves recovery codes. - On normal login, Jellyfin's
SessionStartedevent fires. The plugin checks if the user has 2FA enabled. - If yes, the plugin blocks all subsequent API requests from that session until the user completes 2FA via
/TwoFactorAuth/Login. - After successful verification, a signed
__2fa_trustcookie is set in the browser. For 30 days, that browser doesn't need 2FA again β but new browsers/devices still do. - 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
AllowIndefiniteTrustflag; 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
targetAbiis newer than the server. Check your version under Dashboard β About; upgrade to 10.11+ if needed.
- Open Jellyfin β Dashboard β Plugins β Repositories
- Click + and add this URL:
https://raw.githubusercontent.com/ZL154/JellyfinSecurity/main/manifest.json
- Save and refresh plugins
- Go to the Catalogue tab β install Jellyfin Security
- 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
- Install the plugin from the manifest URL in Dashboard β Plugins β Repositories β Add, then install Two-Factor Authentication from the catalog and restart Jellyfin.
- Go to Dashboard β Plugins β Two-Factor Authentication
- 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.
- 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.
- Optional: configure Notifications (Gotify, ntfy, or webhook) to get alerts when someone triggers a 2FA prompt.
As a user (enroll in 2FA)
- Sign in to Jellyfin normally (no 2FA yet)
- Open Profile β Two-Factor Authentication (or visit
https://your-jellyfin/TwoFactorAuth/Setup) - Click Set up Authenticator App
- Scan the QR code with your authenticator (Google Authenticator, Authy, 1Password, Bitwarden, etc.)
- Enter the 6-digit code shown in the app to confirm
- 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.
- (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:
- Sign in at
/webwith username + password as usual - You will be redirected to the 2FA challenge page
- Enter the 6-digit code from your authenticator
- 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. Nohttps://, no port, no path. - Allowed origins: one per line, full origin including scheme and port β e.g.
https://jellyfin.example.comandhttps://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
- Open the Setup page on the URL you configured above
- Setup β Passkeys card β optionally type a label β Add a passkey
- Browser prompts your platform authenticator (Windows Hello / Touch ID / a YubiKey USB key)
- Tap / scan / confirm β the passkey is saved
Add a passkey on iPhone (Safari)
- Open Safari and visit your Jellyfin HTTPS URL β must be the URL configured as the WebAuthn origin, not the bare LAN IP
- Sign in with username + password + 2FA code
- Setup β Passkeys β label it (e.g. "iPhone") β Add a passkey
- iOS shows "Save passkey for ...?" β confirm with Face ID / Touch ID
- The passkey is saved to iCloud Keychain and syncs to every Apple device on the same Apple ID
Add a passkey on Android (Chrome)
- Open Chrome on Android and visit your Jellyfin HTTPS URL
- Sign in with username + password + 2FA code
- Setup β Passkeys β label it (e.g. "Pixel 8") β Add a passkey
- Android shows "Save passkey to Google Password Manager?" β confirm with fingerprint / face unlock
- 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
- Visit your Jellyfin URL β enter username + password as usual
- At the 2FA challenge page β tap π Use a passkey instead
- The browser prompts your authenticator β confirm with biometric / hardware key
- 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:
- Open the native app and sign in with your username + password
- The app will show "Invalid" or fail to load β that's expected. The server recorded a pending pairing for this device.
- On any already-trusted device (your laptop, phone browser), go to Setup β Devices Waiting for Approval
- You'll see the TV/app listed. Click Trust.
- 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:
- Sign in on the TV/mobile app with your password
- It'll fail once β that's normal, the server recorded a pending pairing
- Approve the device from Setup on any already-trusted browser
- 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:
- Existing SSO link on this Jellyfin user (matched by the IdP's stable
sub) β signs in - Email returned by the IdP matches a per-user email configured in the plugin β signs in (and links for next time)
- Nothing matched + "Auto-create Jellyfin users" is enabled β a new Jellyfin account is created
- 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
- Go to Google Cloud Console β create a project (or pick existing)
- OAuth consent screen β External β fill App name / support email β add your Gmail as a test user β Finish
- Credentials β + Create credentials β OAuth client ID β Web application
- Authorised redirect URIs β add exactly:
https://YOUR-JELLYFIN-HOSTNAME/TwoFactorAuth/Oidc/Callback/google - Save. Copy the Client ID + Client secret from the dialog.
2. Add the provider in Jellyfin
- Jellyfin admin β Plugins β Jellyfin Security β Sign-in Methods tab β "Add providerβ¦"
- 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
| 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 |
| 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/rolesclaim contains at least one of these - Require IdP MFA β refuses sign-in unless the id_token's
amrclaim 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:
| 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:
- Admin clicks a gated action (e.g. Rebuild audit chain).
- UI shows a 2FA challenge modal.
- Admin enters the 6-digit code (or passkey / recovery code).
- 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):
- Admin dashboard β Config β Export.
- Enter a passphrase (10+ chars recommended; longer is better).
- Download the
.json.encenvelope. Treat it like a password β its strength is the passphrase's.
Import (admin):
- Admin dashboard β Config β Import.
- Upload the
.json.encfile β 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
| Factor | Points | What it checks |
|---|---|---|
| Coverage | 30 | % of users enrolled in 2FA |
| 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 |
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).
| 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):
- URL
?lang=deoverride (useful for support / screenshots). - Per-user preference saved from the language picker (
PUT /TwoFactorAuth/Users/{id}/preferences). localStorage(so the picker remembers across sessions).- Server-wide
DefaultLanguagesetting (admin sets in Settings β Hardening). - 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>.jsonand are served via/TwoFactorAuth/translations/{lang}with strong caching. - The shared
tfa-i18n.jshelper exposeswindow.tfaI18n.tr(key, fallback),loadTranslations(lang),applyTranslations(root),renderLanguagePicker(container),getEffectiveLanguage(), and areadypromise so dynamic JS-rendered content doesn't render in English before the bundle loads. /TwoFactorAuth/public-configexposes 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):
- Setup page β Trusted Devices or Paired Devices card.
- Click the Indefinite trust toggle on the device you want to never expire.
- 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 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. 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:
IndexHtmlInjectionMiddlewareβ injects the "Sign in with 2FA" button script into Jellyfin'sindex.htmlTrustCookieMiddlewareβ checks the__2fa_trustcookie 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 toSessionStarted; 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 statesecret.keyβ 32-byte AES-GCM key for TOTP secret encryptioncookie.keyβ 32-byte HMAC-SHA256 key for trust cookie signingaudit.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
| 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 |
| Service integrations breaking | Standard Jellyfin API keys bypass user auth β Sonarr/Radarr unaffected |
| Authelia/Authentik breaking native apps | Native 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.
RequireTwoFactorToDisableflag β 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/Overviewendpoint β 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.jsshared helper βtr() / loadTranslations() / applyTranslations() / renderLanguagePicker() / getEffectiveLanguage()+ areadypromise 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β serverDefaultLanguageβ 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
AllowIndefiniteTrustconfig 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.jsexternalized fromadmin.htmlso Jellyfin's SPAloadViewtemplate-literal stripping no longer breaks the dashboard with aSyntaxError: Unexpected token 'class'./Dashboard/OverviewDTOs flattened to force camelCase JSON serialization.- Setup page stashes
/Users/MeId into a module-scope_myUserIdso the indefinite-trust toggle works withoutwindow.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.readyso 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 usersnow 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.SegoeUIto"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
FontManagerin theRecoveryCodePdfServicestatic 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-platformLatofont 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-x64shipped 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) inRecoveryCodePdfServicepicks the right RID's natives at startup, copies them next to the plugin DLL where QuestPDF probes, andNativeLibrary.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
InvalidOperationExceptioninstead of taking the whole plugin down.
Build
build.shrewritten as a fat-package builder: managed assemblies published once without RID, then per-RID native libs (linux-x64,linux-arm64,linux-musl-x64) bundled intoruntimes/<rid>/native/with a copy at the plugin root.- New
.github/workflows/build-multiarch.ymlruns the fat build inside amcr.microsoft.com/dotnet/sdk:9.0Docker 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.1in 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
Secureflag 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.jsnow served withCache-Control: no-storeso 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
AuthenticationProviderIdon 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/Protoonly honoured when direct peer is inTrustedProxyCidrs(prevents redirect_uri poisoning).- Rate limit on
/Oidc/Login(20 per 5 min per IP). - Bridge HTML uses
JsonSerializer.Serializefor JS context injection + strict CSP +Cache-Control: no-store. returnUrlon 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
/Usersendpoint).
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.DeviceIdfrom Jellyfin's auth response body as a fallback when request headers don't carry a deviceId. That value is always present and authoritative. RegisteredDeviceIdsbypass lookup now uses the same UA-hash normalisation asPairedDevicesso 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 asX-2FA-Timestampso 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.jsonis 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
deviceIdand expiry into the payload. A stolen cookie can no longer be replayed with an attacker-chosenX-Emby-Device-Idheader (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 withjavascript:/data:/file:rejection.
High-severity fixes
PairedDevice/TrustedDevicedeviceIdcomparisons are now case-sensitive (Ordinal). PreviouslyOrdinalIgnoreCaseallowed case-variant bypass.- Pairing approve refuses records with
Guid.Emptyuser or emptydeviceId(phantom-user write prevention). RegisteredDeviceIdscapped at 50 per user with 128-char printable-ASCII validation β no more storage-inflation DoS.IsAuthPathis now anchored to^/Users/β¦instead of substringContainsβ 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-referreron all embedded pages (anti-clickjacking).- Rate limiter is now reverse-proxy aware via
TrustForwardedFor+TrustedProxyCidrs. IPv6 is bucketed by/64to prevent host-rotation bypass. /Verifynow has a per-user rate limit (15 per 15 min) in addition to per-IP./Pairings/Initiateinput (Username,DeviceName) sanitized against control characters and HTML-significant bytes; length-capped at 64.
Medium-severity fixes
inject.jsredirects to a hardcoded/TwoFactorAuth/Challenge?token=β¦path instead of trusting the server body'sChallengePageUrl.TestSmtpadmin endpoint no longer echoesex.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. PairConfirmrecords 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.Verifylength-checks signatures beforeFixedTimeEqualsto 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
deviceIdand 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(TwoFactorAuthProvidernow resolvesIUserManagerlazily viaIApplicationHost). - 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.1in 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:
- β Star this repo β it's free and helps others find it
- π Sponsor on GitHub β one-off or monthly, every dollar reaches the project
- β Buy me a coffee on Ko-fi β one-off tips
Not expected, just appreciated. Security issues reported responsibly are equally valuable.
π License
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.