Skip to content

Architecture

ZL154 edited this page Jun 14, 2026 · 1 revision

Architecture & Security Model

How the plugin works under the hood — for the curious, for contributors, and for anyone auditing before they trust it with their server.


How it works

  1. Each user opts into 2FA via /TwoFactorAuth/Setup — scans a QR code and saves recovery codes.
  2. On login, Jellyfin's SessionStarted event fires; the plugin checks whether the user has 2FA enabled.
  3. If yes, the plugin blocks all subsequent API requests from that session until 2FA is completed via /TwoFactorAuth/Login.
  4. After verification, a signed __2fa_trust cookie is set in the browser — for 30 days that browser skips 2FA; new browsers/devices still prompt.
  5. Enforcement applies regardless of how the user authenticated (web, mobile API, anything that creates a session).

A "Sign in with 2FA" button is injected into Jellyfin's standard login page so enrolled users route to the plugin's form.


Middleware pipeline

The plugin runs five ASP.NET Core middleware components plus an ISessionManager.SessionStarted handler:

  1. IndexHtmlInjectionMiddleware — injects the login-button script into Jellyfin's index.html.
  2. TrustCookieMiddleware — validates the __2fa_trust cookie on auth requests; marks the user pre-verified for the upcoming session.
  3. TwoFactorEnforcementMiddleware — inspects responses from auth endpoints (catches the auth response shape regardless of route).
  4. RequestBlockerMiddleware — returns 401 for API requests from authenticated users who haven't completed 2FA.
  5. LockoutMessageMiddleware — intercepts POST /Users/AuthenticateByName for all users; enforces lockout + empty-password gating and records/clears failed attempts.

Plus AuthenticationEventHandler (hosted service) subscribing to SessionStarted.

Why middleware? Jellyfin only invokes the plugin's IAuthenticationProvider for users whose AuthenticationProviderId is the plugin (OIDC-linked + passkey users). Normal password users go through Jellyfin's DefaultAuthenticationProvider, so universal enforcement (lockout, empty-password) lives in middleware that runs for everyone.


Persistent state

Stored under <config>/plugins/configurations/TwoFactorAuth/:

File Contents
users/{userId}.json Per-user TOTP secret (AES-GCM encrypted), recovery codes (PBKDF2-SHA256 hashed), trusted devices, lockout state, email
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 (hash-chained)
ip-bans.json Active brute-force bans (survives restart)
verified_tokens.json SHA-256 hashes of verified tokens (rehydrates verified sessions after restart)

All writes use atomic write-then-rename so a crash mid-write can't corrupt state.


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 space Per-IP rate limit (10/min), per-challenge attempt limit (5), per-user lockout
Stolen recovery code Marked used immediately on validation regardless of password outcome
Stolen trust cookie HMAC-SHA256 signed with a persistent key; HttpOnly, Secure, SameSite=Strict; tied to a revocable server-side record
Account enumeration Identical "invalid credentials" message whether password, user, or code is wrong
Disk corruption mid-write Atomic write-then-rename for all user state
TOTP secret theft from disk AES-GCM encrypted with a persistent 32-byte key
Replay attacks on TOTP Used time-steps tracked per user
Timing attacks CryptographicOperations.FixedTimeEquals on all secret comparisons
SSRF via OIDC / webhook / picture URLs Egress guard refusing RFC1918 / loopback / link-local (incl. cloud metadata) unless explicitly allowed per-provider
Service integrations breaking Standard Jellyfin API keys bypass user auth — Sonarr/Radarr unaffected
Audit-log tampering Hash chain; the Diagnostics tab verifies it on demand

The full threat model — including what the plugin intentionally does not defend against — lives in SECURITY.md.


API endpoints (selected)

User-facing (anonymous or self-auth)

GET  /TwoFactorAuth/Login            — login page (HTML)
GET  /TwoFactorAuth/Setup            — enrollment page (HTML)
POST /TwoFactorAuth/Authenticate     — username + password + code login
POST /TwoFactorAuth/Verify           — verify code against challenge token
POST /TwoFactorAuth/Email/Send       — request an email OTP for the current challenge
POST /TwoFactorAuth/Setup/Totp       — generate TOTP secret + QR (auth)
POST /TwoFactorAuth/RecoveryCodes/Generate  — generate recovery codes (auth)
GET  /TwoFactorAuth/Devices          — own trusted devices (auth)

Admin-only (RequiresElevation)

GET    /TwoFactorAuth/Users                  — all users with 2FA status
POST   /TwoFactorAuth/Users/{id}/Toggle      — enable/disable 2FA for a user
GET    /TwoFactorAuth/AuditLog               — login history
GET    /TwoFactorAuth/Pairings               — pending pairings
POST   /TwoFactorAuth/Sessions/{id}/Revoke   — revoke an active session

See the source for the complete list (OIDC, passkeys, IP bans, config export, etc.).


Limitations

  • Native mobile apps can't run a browser 2FA flow — use device pairing or app passwords.
  • Passkeys are browser-only (WebAuthn has no native-app hook).
  • Quick Connect sessions are still subject to 2FA enforcement until the user completes the flow.
  • Email OTP requires admin-set per-user email — Jellyfin's User entity exposes email inconsistently across versions.
  • The trust cookie isn't bound to IP — a stolen cookie works from any IP for 30 days within the signed deviceId; revoke the device to kill it.

Trust signals

Everything is automated and public: CI (full xUnit suite), CodeQL (security-extended), OpenSSF Scorecard, Dependabot, signed releases with .sha256 checksums, and security advisories.

Clone this wiki locally