-
Notifications
You must be signed in to change notification settings - Fork 4
Architecture
How the plugin works under the hood — for the curious, for contributors, and for anyone auditing before they trust it with their server.
- Each user opts into 2FA via
/TwoFactorAuth/Setup— scans a QR code and saves recovery codes. - On login, Jellyfin's
SessionStartedevent fires; the plugin checks whether the user has 2FA enabled. - If yes, the plugin blocks all subsequent API requests from that session until 2FA is completed via
/TwoFactorAuth/Login. - After verification, a signed
__2fa_trustcookie is set in the browser — for 30 days that browser skips 2FA; new browsers/devices still prompt. - 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.
The plugin runs five ASP.NET Core middleware components plus an ISessionManager.SessionStarted handler:
-
IndexHtmlInjectionMiddleware— injects the login-button script into Jellyfin'sindex.html. -
TrustCookieMiddleware— validates the__2fa_trustcookie on auth requests; marks the user pre-verified for the upcoming session. -
TwoFactorEnforcementMiddleware— inspects responses from auth endpoints (catches the auth response shape regardless of route). -
RequestBlockerMiddleware— returns 401 for API requests from authenticated users who haven't completed 2FA. -
LockoutMessageMiddleware— interceptsPOST /Users/AuthenticateByNamefor 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
IAuthenticationProviderfor users whoseAuthenticationProviderIdis the plugin (OIDC-linked + passkey users). Normal password users go through Jellyfin'sDefaultAuthenticationProvider, so universal enforcement (lockout, empty-password) lives in middleware that runs for everyone.
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.
| 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.
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)
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.).
- 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
Userentity 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.
Everything is automated and public: CI (full xUnit suite), CodeQL (security-extended), OpenSSF Scorecard, Dependabot, signed releases with .sha256 checksums, and security advisories.
Getting started
Features
Reference
Help