name: jellyfin-security-plugin description: Add native two-factor authentication, passkeys, SSO, brute-force protection, and audit logging to Jellyfin media servers via server-side plugin. triggers: - add two-factor authentication to jellyfin - set up jellyfin totp or passkeys - configure jellyfin sso with oidc - protect jellyfin with brute force ip banning - implement jellyfin device pairing for tv clients - enable jellyfin lan bypass for local networks - audit jellyfin login attempts and security events - integrate jellyfin with authentik or authelia
Jellyfin Security Plugin
Skill by ara.so — Security Skills collection.
What It Does
JellyfinSecurity is a comprehensive authentication and hardening plugin for Jellyfin media servers (10.11+). It adds:
- Multi-factor authentication: TOTP (Authy/Google Authenticator), passkeys (WebAuthn/FIDO2), email OTP, and recovery codes
- Single Sign-On: OIDC provider integration (Authentik, Authelia, Keycloak, Pocket ID, etc.)
- Brute-force protection: IP-based rate limiting and banning with configurable thresholds
- Device management: TV device pairing via QR code, trusted browser tokens
- Network controls: LAN bypass, per-user IP allowlist, impossible-travel detection
- Audit logging: Full login/logout/config-change event tracking
- Step-up authentication: Re-verify TOTP/passkey/IdP before sensitive admin actions
Server-side enforcement works with all Jellyfin clients (web, Android, iOS, Roku, Fire TV, Kodi) and service integrations (Sonarr, Radarr, Tautulli).
Installation
Via Jellyfin Admin Dashboard (Recommended)
- Open Jellyfin → Admin Dashboard → Plugins → Repositories
- Add repository:
- Name:
JellyfinSecurity - URL:
https://raw.githubusercontent.com/ZL154/JellyfinSecurity/main/manifest.json
- Name:
- Go to Catalog → search "Security" → Install
- Restart Jellyfin server
Manual Installation
# Download latest release
RELEASE_URL="https://github.com/ZL154/JellyfinSecurity/releases/latest/download/JellyfinSecurity.zip"
PLUGIN_DIR="/var/lib/jellyfin/plugins/JellyfinSecurity"
mkdir -p "$PLUGIN_DIR"
curl -L "$RELEASE_URL" -o /tmp/jellyfin-security.zip
unzip /tmp/jellyfin-security.zip -d "$PLUGIN_DIR"
chown -R jellyfin:jellyfin "$PLUGIN_DIR"
# Restart Jellyfin
systemctl restart jellyfin
Verify installation:
# Check plugin loaded
journalctl -u jellyfin | grep "JellyfinSecurity"
# Should see: "JellyfinSecurity v2.5.8 loaded"
Configuration
Admin Dashboard Settings
Navigate to Dashboard → Plugins → JellyfinSecurity → Settings.
Core Authentication
# Enable/disable 2FA methods
TOTPEnabled: true # TOTP (Authenticator apps)
PasskeysEnabled: true # WebAuthn/FIDO2 hardware keys
EmailOTPEnabled: false # Email one-time passwords
RecoveryCodesEnabled: true # Backup codes (auto-generated with TOTP)
# OIDC/SSO providers
OIDCProviders:
- Name: "Authentik"
ClientID: "jellyfin-client"
ClientSecret: "${OIDC_CLIENT_SECRET}" # Use environment variable
Authority: "https://auth.example.com/application/o/jellyfin/"
Scopes: "openid profile email groups"
AllowPrivateEndpoints: true # For LAN-only IdPs
Brute-Force Protection
BruteForceEnabled: true
MaxFailedAttempts: 5 # Attempts before temporary ban
LockoutDuration: 900 # Seconds (15 minutes)
PermanentBanThreshold: 20 # Failed attempts → permanent ban
LAN Bypass
LANBypassEnabled: true
LANBypassCIDRs: # RFC1918 + local ranges
- "192.168.0.0/16"
- "10.0.0.0/24" # Be specific to avoid SEC-H3 guard
- "172.16.0.0/12"
- "fd00::/8" # IPv6 ULA
TrustedProxyCIDRs: # Your reverse proxy IPs only
- "10.0.1.5/32" # Nginx/Traefik container IP
⚠️ Trusted Proxy Pitfall: Do NOT set broad ranges like 10.0.0.0/8 in TrustedProxyCIDRs — the SEC-H3 guard will refuse LAN bypass for direct clients if their IP falls within a trusted-proxy range but no X-Forwarded-For header is present. Use /32 (single IP) or tight /24 subnets for your actual reverse proxy.
Device Pairing (TV Clients)
TVPairingEnabled: true
PairingCodeExpiration: 300 # Seconds (5 minutes)
Step-Up Authentication
StepUpLevel: AllConfigChanges # Re-verify TOTP/passkey before:
# - Plugin settings changes
# - User permission changes
# - IP allowlist modifications
Usage Patterns
End-User Enrollment (TOTP)
After installing the plugin, users enroll via Jellyfin web UI:
- User → Profile → Two-Factor Authentication
- Click Enable TOTP
- Scan QR code with Authy/Google Authenticator
- Enter 6-digit code to confirm
- Save 8 recovery codes (required for account recovery)
No user code changes needed — the plugin intercepts /Users/AuthenticateByName at the middleware level.
TV Device Pairing (Roku, Fire TV, etc.)
For clients without keyboard input:
// TV app requests pairing code
POST /JellyfinSecurity/PairDevice
{
"DeviceName": "Living Room Roku",
"DeviceId": "roku-device-12345"
}
// Response contains code + QR URL
{
"pairingCode": "AB12-CD34",
"qrCodeUrl": "/JellyfinSecurity/PairDeviceQR?code=AB12-CD34",
"expiresAt": "2026-06-12T12:05:00Z"
}
// User approves via Admin Dashboard → Pending Pairs
// TV polls for approval:
GET /JellyfinSecurity/CheckPairingStatus?code=AB12-CD34
// After approval, use returned token in X-Device-Token header
Trusted Browser Token (Web Client)
After successful 2FA login, the plugin sets a signed cookie:
Set-Cookie: JellyfinSecurity-TrustedDevice=<hmac-signed-token>;
HttpOnly; Secure; SameSite=Strict; Max-Age=7776000
Subsequent logins from the same browser skip 2FA for 90 days (configurable). The token is bound to User-Agent + IP subnet (configurable prefix length).
OIDC Sign-In Flow
// Plugin auto-registers endpoints at startup
// User clicks "Sign in with Authentik" button on login page
// 1. Initiate OIDC flow
GET /JellyfinSecurity/OIDC/Authorize?providerId=authentik
// 2. User redirects to IdP, signs in, returns to callback
GET /JellyfinSecurity/OIDC/Callback?code=...&state=...
// 3. Plugin exchanges code for tokens, creates/links Jellyfin user
// 4. Sets Jellyfin auth token cookie, redirects to /web/index.html
Userinfo claim mapping (auto-merged):
preferred_usernameoremail→ Jellyfin usernameemail→ Jellyfin emailname→ Jellyfin display namegroups→ Jellyfin user policies (ifSyncGroupsEnabled: true)
Per-User IP Allowlist
# Admin Dashboard → JellyfinSecurity → Users → Edit User
AllowedIPs:
- "203.0.113.0/24" # Office network
- "198.51.100.42/32" # Home static IP
User cannot authenticate from any IP outside this list. Leave empty to disable IP restrictions for that user.
Programmatic API Access (Sonarr, Radarr, etc.)
Option 1: API Key Bypass (recommended for service integrations)
# Admin → JellyfinSecurity → Settings
APIKeyBypassEnabled: true
Services using X-Emby-Token header are exempt from 2FA. Generate API key in Jellyfin Dashboard → API Keys.
curl -H "X-Emby-Token: ${JELLYFIN_API_KEY}" \
https://jellyfin.example.com/Users/Me
Option 2: Device Token
Pair device once, then include token in every request:
# Pair device (one-time)
curl -X POST https://jellyfin.example.com/JellyfinSecurity/PairDevice \
-H "Content-Type: application/json" \
-d '{"DeviceName":"Sonarr","DeviceId":"sonarr-instance-1"}'
# Approve pairing via Admin UI, retrieve token from response
# Use token in subsequent requests
curl -H "X-Device-Token: ${DEVICE_TOKEN}" \
-H "X-Emby-Token: ${JELLYFIN_API_KEY}" \
https://jellyfin.example.com/Library/Movies
Code Examples
Custom Middleware Integration (C#)
If you're building a separate Jellyfin plugin that needs to hook into JellyfinSecurity's verified-session state:
using JellyfinSecurity.Services;
using Microsoft.AspNetCore.Http;
public class CustomAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly ISessionVerifier _sessionVerifier;
public CustomAuthMiddleware(
RequestDelegate next,
ISessionVerifier sessionVerifier)
{
_next = next;
_sessionVerifier = sessionVerifier;
}
public async Task InvokeAsync(HttpContext context)
{
var authToken = context.Request.Headers["X-Emby-Token"].FirstOrDefault();
if (string.IsNullOrEmpty(authToken))
{
context.Response.StatusCode = 401;
return;
}
// Check if this token passed 2FA verification
if (!_sessionVerifier.IsVerified(authToken))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("2FA verification required");
return;
}
await _next(context);
}
}
Audit Log Query (C#)
using JellyfinSecurity.Data;
using JellyfinSecurity.Models;
public class AuditLogService
{
private readonly IAuditLogStore _auditLog;
public async Task<List<AuditEvent>> GetFailedLoginsAsync(
DateTime since,
int limit = 100)
{
return await _auditLog.QueryAsync(
eventType: AuditEventType.LoginFailed,
startDate: since,
limit: limit
);
}
public async Task<List<AuditEvent>> GetUserActionsAsync(Guid userId)
{
return await _auditLog.QueryByUserAsync(userId);
}
}
Email OTP Service Configuration (C#)
using JellyfinSecurity.Configuration;
var smtpConfig = new EmailOTPConfiguration
{
Enabled = true,
SMTPHost = "smtp.gmail.com",
SMTPPort = 587,
UseTLS = true,
Username = "noreply@example.com",
Password = Environment.GetEnvironmentVariable("SMTP_PASSWORD"),
FromAddress = "noreply@example.com",
FromName = "Jellyfin Security",
CodeExpiration = 300 // 5 minutes
};
HIBP Password Check (C#)
The plugin includes k-anonymity HIBP integration for password breach detection:
using JellyfinSecurity.Services;
public class PasswordValidator
{
private readonly IHIBPService _hibp;
public async Task<bool> IsPasswordCompromisedAsync(string password)
{
// Sends only first 5 chars of SHA-1 hash to HIBP
// (k-anonymity model — password never leaves server in plaintext)
return await _hibp.IsPasswordPwnedAsync(password);
}
}
Common Workflows
Scenario: User Locked Out (Forgot TOTP Device)
Admin recovery via Dashboard:
- Dashboard → JellyfinSecurity → Users
- Select locked-out user → Reset 2FA
- User can log in with password only (2FA disabled)
- User re-enrolls TOTP from profile page
User self-recovery (if recovery codes saved):
- Login page → Use Recovery Code
- Enter one of the 8 saved codes (single-use)
- After login, user can disable TOTP or generate new QR
Scenario: Impossible Travel Alert
# Admin → Settings
ImpossibleTravelEnabled: true
ImpossibleTravelThreshold: 500 # km/hour (flags physically impossible logins)
When detected:
- Login blocked automatically
- Admin email notification sent (if
AdminEmailAlerts: true) - Audit log entry:
AuditEventType.ImpossibleTravel - Admin must manually unban IP via Dashboard → Banned IPs
Scenario: SSO-Only Deployment (Hide Built-In Login)
# Admin → Settings
ShowBuiltIn2FAButton: false
ShowBuiltInPasskeyButton: false
OIDCProviders:
- Name: "Corporate SSO"
ClientID: "${OIDC_CLIENT_ID}"
ClientSecret: "${OIDC_CLIENT_SECRET}"
Authority: "https://sso.corp.example.com"
Login page shows only "Sign in with Corporate SSO" button.
Troubleshooting
LAN Bypass Not Working
Symptom: Local clients (192.168.x.x) still prompted for 2FA despite LANBypassEnabled: true.
Diagnosis:
# Check Jellyfin logs for SEC-H3 guard message
journalctl -u jellyfin | grep "SEC-H3"
# Example output:
# "SEC-H3: Client IP 192.168.1.100 is within trusted proxy range 192.168.0.0/16
# but no X-Forwarded-For header present — refusing LAN bypass"
Fix:
If behind reverse proxy, ensure
X-Forwarded-Forheader is set:# Nginx proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;# Traefik (dynamic config) http: middlewares: jellyfin-headers: headers: customRequestHeaders: X-Forwarded-For: "" # Traefik auto-populatesIf direct LAN access (no proxy), tighten
TrustedProxyCIDRs:# BEFORE (too broad): TrustedProxyCIDRs: - "192.168.0.0/16" # Includes LAN clients! # AFTER (proxy IP only): TrustedProxyCIDRs: - "192.168.1.5/32" # Nginx container IP
Step-Up Modal Not Appearing
Symptom: Admin clicks Save Settings but nothing happens (v2.5.7 and earlier).
Fix: Upgrade to v2.5.8+. The admin UI now uses step-up-aware fetch wrapper:
// Fixed in v2.5.8 — PluginConfigPage.js
async function saveConfiguration() {
await stepUpAwareFetch('/JellyfinSecurity/Configuration', {
method: 'POST',
body: JSON.stringify(config)
});
}
OIDC Redirect URI Mismatch
Symptom: IdP returns invalid_redirect_uri error.
Diagnosis:
# Check Jellyfin base URL
curl -s http://localhost:8096/System/Configuration | jq -r '.BaseUrl'
Fix:
Ensure
BaseUrlmatches public-facing URL in Jellyfin Dashboard → Networking:BaseUrl: "https://jellyfin.example.com" # Must include https:// if behind SSL proxyRegister exact redirect URI in IdP:
https://jellyfin.example.com/JellyfinSecurity/OIDC/CallbackIf using Docker + reverse proxy, verify
X-Forwarded-Protoheader:proxy_set_header X-Forwarded-Proto $scheme;
Device Token Expired After Server Restart
Symptom: All TV clients require re-pairing after docker restart jellyfin (v2.5.6 and earlier).
Fix: Upgrade to v2.5.7+. Verified tokens are now persisted to verified-tokens.json:
# Check persistence file exists
ls -lh /config/data/jellyfinsecurity/verified-tokens.json
# Should see entries like:
# {"TokenHash":"sha256:abcd1234...", "VerifiedAt":"2026-06-12T10:00:00Z"}
TOTP Code Rejected (Time Sync Issue)
Symptom: Valid TOTP code from Authy/Google Authenticator shows "Invalid code."
Diagnosis:
# Check server time
timedatectl
# Check time skew tolerance (default ±1 step = 60 seconds)
journalctl -u jellyfin | grep "TOTP time skew"
Fix:
Enable NTP on Jellyfin server:
timedatectl set-ntp trueIncrease skew window (admin settings):
TOTPTimeSkew: 2 # Allow ±2 steps (120 seconds)
High CPU Usage from Audit Log
Symptom: jellyfin process consuming high CPU after enabling audit logging.
Fix:
Enable log rotation:
AuditLogEnabled: true AuditLogRotation: true AuditLogMaxSize: 104857600 # 100 MB AuditLogMaxAge: 30 # DaysExclude high-frequency events:
AuditLogExcludedEvents: - "HeartbeatReceived" - "SessionActivity"
Security Considerations
What This Plugin Defends Against
- ✅ Credential stuffing (brute-force IP banning)
- ✅ Phishing (TOTP/passkeys immune to credential reuse)
- ✅ Unauthorized LAN access (IP allowlist, impossible travel)
- ✅ Compromised passwords (HIBP integration)
- ✅ Session hijacking (token binding to User-Agent + IP)
- ✅ Privilege escalation (step-up auth for admin actions)
What This Plugin Does NOT Defend Against
- ❌ Server-side vulnerabilities in Jellyfin core (keep Jellyfin updated)
- ❌ Client-side XSS (use Content-Security-Policy headers in reverse proxy)
- ❌ TLS/certificate issues (configure reverse proxy with valid certs)
- ❌ Physical access to server (encrypt
/configvolume) - ❌ Supply-chain attacks on plugin dependencies (verify release SHA-256)
Recommended Deployment Hardening
# Nginx reverse proxy config
server {
listen 443 ssl http2;
server_name jellyfin.example.com;
ssl_certificate /etc/letsencrypt/live/jellyfin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jellyfin.example.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Pass real client IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
location / {
proxy_pass http://jellyfin:8096;
}
}
# Docker Compose with read-only filesystem
services:
jellyfin:
image: jellyfin/jellyfin:latest
read_only: true
tmpfs:
- /tmp
- /var/tmp
volumes:
- /path/to/config:/config # Must be writable for plugin data
- /path/to/media:/media:ro
environment:
- JELLYFIN_PublishedServerUrl=https://jellyfin.example.com
Testing
The plugin includes 254 xUnit tests covering security-critical paths:
# Run full test suite
git clone https://github.com/ZL154/JellyfinSecurity.git
cd JellyfinSecurity
dotnet test --logger "console;verbosity=detailed"
# Run specific test categories
dotnet test --filter "Category=Crypto" # TOTP, passkey, cookie HMAC
dotnet test --filter "Category=Middleware" # Step-up, LAN bypass, brute-force
dotnet test --filter "Category=OIDC" # SSO flows, token exchange
Key test coverage:
- TOTP replay protection (time-step validation)
- Recovery code PBKDF2 hashing (100k iterations)
- Trusted browser token HMAC verification
- CIDR parser edge cases (IPv6, /0, /128)
- X-Forwarded-For trust-walk (multi-proxy chains)
- AES-GCM v2 authenticated encryption
- HIBP k-anonymity hashing (SHA-1 prefix)
- Step-up challenge consumption (single-use tokens)
API Reference
Public Endpoints
| Endpoint | Method | Description |
|---|---|---|
/JellyfinSecurity/OIDC/Authorize |
GET | Initiate OIDC flow |
/JellyfinSecurity/OIDC/Callback |
GET | OIDC redirect callback |
/JellyfinSecurity/PairDevice |
POST | Request TV pairing code |
/JellyfinSecurity/CheckPairingStatus |
GET | Poll for pairing approval |
/JellyfinSecurity/VerifyTOTP |
POST | Submit TOTP code |
/JellyfinSecurity/VerifyPasskey |
POST | Complete WebAuthn ceremony |
/JellyfinSecurity/SendEmailOTP |
POST | Request email OTP |
Admin-Only Endpoints (Require Step-Up if Enabled)
| Endpoint | Method | Description |
|---|---|---|
/JellyfinSecurity/Configuration |
GET/POST | Plugin settings |
/JellyfinSecurity/Users |
GET | List users with 2FA status |
/JellyfinSecurity/Users/{id} |
GET/PUT | User-specific config |
/JellyfinSecurity/Users/{id}/ResetTOTP |
POST | Disable user's 2FA |
/JellyfinSecurity/PendingPairs |
GET | List awaiting approval |
/JellyfinSecurity/ApprovePair |
POST | Approve TV pairing |
/JellyfinSecurity/DenyPair |
POST | Reject TV pairing |
/JellyfinSecurity/AuditLog |
GET | Query security events |
Further Resources
- GitHub Repository: https://github.com/ZL154/JellyfinSecurity
- Security Policy: SECURITY.md
- OpenSSF Scorecard: https://securityscorecards.dev/viewer/?uri=github.com/ZL154/JellyfinSecurity
- Jellyfin Plugin Portal: https://jellyfin.org/plugins/
- TOTP RFC 6238: https://datatracker.ietf.org/doc/html/rfc6238
- WebAuthn Spec: https://www.w3.org/TR/webauthn-2/
For security issues, email the maintainer directly (see SECURITY.md) or file a private advisory via GitHub Security.