TL;DR

Someone asked me on Reddit for a comprehensive guide to securing a public-facing Jellyfin instance, so here it is. The short answer I gave was: fail2ban, automate patching, implement OAuth, and download an IP block list. This post expands all four into actionable steps and adds a fifth option — IP whitelisting with a DDNS-aware Python cron job — plus the honest answer that a VPN eliminates most of this complexity entirely.

I personally do not expose Jellyfin publicly. I run WireGuard and am lucky enough to have VPN access to my network, which means the threat model collapses down to “protect the VPN endpoint.” But if you need public access, here is how I would harden it.

Why this matters

A self-hosted Jellyfin instance on port 8096 (or behind a reverse proxy on 443) is visible to every scanner on the internet within minutes of going live. It is not hypothetical — Shodan indexes Jellyfin instances. Attackers run credential stuffing attacks against every login form they find. If your media server has an account with a weak password, it will be compromised.

The risk is not just “someone watches your movies.” A compromised host on your home network is a pivot point into everything else: NAS, cameras, other services. Take it seriously.

Layer 1: Fail2ban

Fail2ban monitors log files and bans IPs that trigger too many failed authentication attempts. For Jellyfin, you need a custom filter because Jellyfin does not use a standard log format that fail2ban ships with.

Install fail2ban

# Debian/Ubuntu
sudo apt install fail2ban

# Or if you're running Jellyfin in Docker, run fail2ban on the host

Create a Jellyfin filter

# /etc/fail2ban/filter.d/jellyfin.conf
[Definition]
failregex = ^.*Authentication request for .* has been denied \(IP: "<HOST>"\).*$
ignoreregex =

Jellyfin logs auth failures to /var/log/jellyfin/jellyfin*.log by default. Adjust the path if you changed it or are using Docker volume mounts.

Create the jail

# /etc/fail2ban/jail.d/jellyfin.conf
[jellyfin]
enabled   = true
port      = http,https
filter    = jellyfin
logpath   = /var/log/jellyfin/jellyfin*.log
maxretry  = 5
findtime  = 600
bantime   = 3600

This bans an IP for 1 hour after 5 failed attempts within 10 minutes. Tune bantime upward aggressively — there is no reason to give credential stuffers a second chance after an hour.

sudo systemctl enable fail2ban --now
sudo fail2ban-client status jellyfin   # verify it loaded

If Jellyfin is behind a reverse proxy, make sure it is logging the real client IP, not the proxy IP. In Traefik, that means X-Forwarded-For passthrough. In Nginx, it means set_real_ip_from and real_ip_header X-Forwarded-For. If fail2ban bans your proxy’s IP, you will take down all access in one shot.

Layer 2: Automate patching

CVEs hit Jellyfin regularly. Running an unpatched instance means you are one Shodan search away from being compromised via a known exploit, not a brute-force.

If you’re running the native package

# Debian/Ubuntu — add this to a weekly cron
sudo apt update && sudo apt install --only-upgrade jellyfin -y
# /etc/cron.d/jellyfin-upgrade
0 3 * * 0  root  apt update -qq && apt install --only-upgrade jellyfin -y >> /var/log/jellyfin-upgrade.log 2>&1

If you’re running Docker / Kubernetes

Use Renovate or Dependabot to auto-update your image tags. On my k3s cluster, Renovate opens a PR whenever a new jellyfin/jellyfin image is published. The PR runs CI, and I merge it. No manual tracking.

# renovate.json
{
  "extends": ["config:base"],
  "packageRules": [
    {
      "matchPackageNames": ["jellyfin/jellyfin"],
      "automerge": false,
      "schedule": ["after 9pm on sunday"]
    }
  ]
}

Staying within a minor version behind should be your baseline. Being more than one major version behind is unacceptable for an internet-facing service.

Layer 3: OAuth / SSO in front of Jellyfin

Jellyfin’s built-in auth is a username/password form. If that form is publicly accessible, it is a target. Putting an SSO layer in front of it means attackers must bypass your identity provider (Google, GitHub, etc.) before they can even reach the Jellyfin login.

Option A: OAuth2 Proxy (simpler)

OAuth2 Proxy is a reverse proxy that requires Google (or another OIDC provider) authentication before forwarding requests downstream.

# docker-compose snippet
oauth2-proxy:
  image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
  command:
    - --provider=google
    - --email-domain=yourdomain.com      # restrict to your Google Workspace domain
    - --upstream=http://jellyfin:8096
    - --http-address=0.0.0.0:4180
    - --cookie-secret=<32-byte-random>
    - --client-id=<google-client-id>
    - --client-secret=<google-client-secret>
    - --redirect-url=https://jellyfin.yourdomain.com/oauth2/callback

Point your reverse proxy (Nginx/Traefik/Caddy) at port 4180 instead of 8096. Now hitting Jellyfin requires a valid Google login first.

Limitation: the Jellyfin mobile app and the jellyfin-mpv-shim desktop client will not work cleanly with OAuth2 Proxy because they do not handle OIDC redirects. For clients, you need to either exempt specific paths or use the LDAP plugin.

Option B: Authentik (more powerful)

Authentik is a full identity platform. It supports forwardAuth with Traefik, which means you can protect the web UI via SSO while allowing API paths (/Users/, /Sessions/, etc.) to pass through for app authentication. I wrote a full Authentik planning post if you want the deep dive.

The practical summary: Authentik + Traefik forwardAuth gives you SSO on the web UI, plus the ability to whitelist API routes for mobile clients. It is more setup but more flexible.

The Jellyfin SSO plugin

Jellyfin also has an SSO plugin that integrates OIDC directly into the Jellyfin login page. This works better for mobile clients since the auth happens inside the app flow rather than via an external proxy. Worth evaluating alongside OAuth2 Proxy.

Layer 4: IP block lists

Block known malicious IP ranges before they reach Jellyfin at all. There are maintained block lists of IPs associated with botnets, scanners, VPN exit nodes, and Tor relays.

With UFW (simple)

# Download a block list — ipset format
wget -O /tmp/blocklist.txt https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset

# Apply with ipset
sudo ipset create blocklist hash:net
while read ip; do
  [[ "$ip" =~ ^#|^$ ]] && continue
  sudo ipset add blocklist "$ip" 2>/dev/null
done < /tmp/blocklist.txt

sudo iptables -I INPUT -m set --match-set blocklist src -j DROP

Automate refreshing this weekly via cron. The FireHOL blocklist project is a good maintained source.

With Nginx / Traefik

Nginx supports deny directives directly in the config. Traefik supports IP allowlists and denylists via middleware. For large block lists, ipset + iptables at the kernel level is more efficient than application-layer filtering.

Layer 5: IP whitelisting with DDNS

If your Jellyfin is only for you and a handful of trusted people, the cleanest option is to not accept connections from anyone except known IPs. The problem: most home internet connections have dynamic IPs. This is where DDNS + a Python cron job comes in.

The concept

  1. Each authorized user sets up a DDNS hostname that always resolves to their current home IP (e.g., bob.dyndns.example.com).
  2. A cron job on your server resolves those hostnames every few minutes and updates the firewall allowlist.
  3. Only IPs in the allowlist can reach Jellyfin at all.

DDNS options

  • Duck DNS — free, simple API, works well for home use
  • Cloudflare — if you already use CF for DNS, their API makes DDNS trivial
  • Afraid.org — long-running free DDNS service
  • No-IP — popular, free tier with monthly renewal

Most routers support DDNS natively (UniFi, OpenWRT, DD-WRT all have built-in clients). Users just configure their router, get a hostname, and send you the hostname.

Python cron script

#!/usr/bin/env python3
"""
jellyfin-whitelist-updater.py
Resolves DDNS hostnames and updates UFW rules to allow only those IPs.
Run via cron every 5 minutes.
"""

import socket
import subprocess
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger(__name__)

# Hostnames you trust — one per authorized user
ALLOWED_HOSTNAMES = [
    "alice.duckdns.org",
    "bob.duckdns.org",
    "yourrouter.duckdns.org",  # your own home IP
]

JELLYFIN_PORT = 443  # or 8096 if not behind a proxy
STATE_FILE = Path("/var/lib/jellyfin-whitelist/last_ips.txt")

def resolve(hostname: str) -> str | None:
    try:
        return socket.gethostbyname(hostname)
    except socket.gaierror as e:
        log.warning(f"Failed to resolve {hostname}: {e}")
        return None

def get_current_rules() -> set[str]:
    result = subprocess.run(
        ["ufw", "status", "numbered"],
        capture_output=True, text=True, check=True
    )
    ips = set()
    for line in result.stdout.splitlines():
        if f"{JELLYFIN_PORT}" in line and "ALLOW" in line:
            parts = line.split()
            for part in parts:
                if part.count(".") == 3:
                    ips.add(part)
    return ips

def allow_ip(ip: str):
    subprocess.run(
        ["ufw", "allow", "from", ip, "to", "any", "port", str(JELLYFIN_PORT)],
        check=True, capture_output=True
    )
    log.info(f"Allowed {ip}")

def revoke_ip(ip: str):
    # UFW does not have a simple 'deny from IP port X' delete — delete by rule number
    result = subprocess.run(
        ["ufw", "status", "numbered"],
        capture_output=True, text=True, check=True
    )
    for line in result.stdout.splitlines():
        if ip in line and f"{JELLYFIN_PORT}" in line:
            num = line.split("]")[0].strip("[ ")
            subprocess.run(
                ["ufw", "--force", "delete", num],
                check=True, capture_output=True
            )
            log.info(f"Revoked {ip}")
            return

def main():
    resolved = {h: resolve(h) for h in ALLOWED_HOSTNAMES}
    current_ips = {ip for ip in resolved.values() if ip}

    if STATE_FILE.exists():
        previous_ips = set(STATE_FILE.read_text().splitlines())
    else:
        previous_ips = set()

    to_add = current_ips - previous_ips
    to_remove = previous_ips - current_ips

    for ip in to_remove:
        revoke_ip(ip)
    for ip in to_add:
        allow_ip(ip)

    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text("\n".join(current_ips))
    log.info(f"Whitelist updated: {current_ips}")

if __name__ == "__main__":
    main()
# /etc/cron.d/jellyfin-whitelist
*/5 * * * *  root  /usr/bin/python3 /opt/jellyfin-whitelist-updater.py >> /var/log/jellyfin-whitelist.log 2>&1

This is unsophisticated by design. It resolves hostnames, diffs against the previous state, and updates UFW. If DNS resolution fails (network blip), the previous IPs remain in place — no one gets locked out. If an IP changes, the old rule is revoked and the new one added within 5 minutes.

For Cloudflare DNS instead of UFW, the same logic applies but you call the Cloudflare API to update a firewall rule or WAF IP list instead of shelling out to ufw.

The honest answer: use a VPN

All of the above is real and worth doing if you must have public access. But the honest answer is that a VPN removes most of this threat surface entirely.

I run WireGuard on my network. Jellyfin is not exposed on any public port. When I want to watch something away from home, I connect to WireGuard and access Jellyfin exactly as if I were on my LAN. My friends who have access also have WireGuard credentials.

The only public attack surface is the WireGuard UDP port, which:

  • Does not respond to unauthenticated packets (stealth port — scanners cannot detect it as WireGuard)
  • Requires a valid key pair, not a password
  • Has no brute-forceable login form

If you want easy setup, Tailscale is WireGuard under the hood with a control plane that handles key exchange, NAT traversal, and ACLs. For a homelab with a handful of trusted users, Tailscale’s free tier is more than enough. You install the agent, add your friends as users, and Jellyfin never touches the public internet.

I am lucky to have VPN infrastructure in place already — it makes this entire problem trivial. If you are starting from scratch and public access is not a hard requirement, set up Tailscale first. It takes 20 minutes and eliminates 90% of the threat model before you have written a single firewall rule.

Putting it all together

If public access is truly required, layer these controls:

LayerWhat it doesPriority
VPNEliminates public exposure entirelyBest option if viable
IP whitelist + DDNS cronRestricts access to known IPsHigh — minimal attack surface
OAuth2 Proxy / AuthentikForces SSO before reaching JellyfinHigh — eliminates password attacks
Fail2banBans IPs after failed authMedium — defense in depth
IP block listsDrops known-bad IPs at kernel levelMedium — reduces noise
Automated patchingKeeps CVEs patchedAlways — non-negotiable

You do not have to implement all of these. VPN + automated patching is better than no VPN + all five application-layer controls. Start with what blocks the most risk per hour of effort.

What’s next

I am working through a phased Authentik deployment for my own cluster — once that is live I will write up the Traefik forwardAuth config with Jellyfin-specific path exclusions for mobile clients. If you run a public Jellyfin and have the API path exemptions working cleanly, I would like to hear what your setup looks like.


Don’t have a homelab? This same architecture works on any VPS running Docker. A DigitalOcean Droplet is a cheap way to experiment with this stack before committing to home hardware.