Putting a lock on Jellyfin's media streams
Jellyfin will stream your media to anyone who asks for it. Here's how I gated it in my homelab, and what a simpler setup can do until it's fixed upstream.
I run Jellyfin at home, and it faces the internet so family, friends and my phone on cellular can watch things. It has two-factor auth on logins, sits behind OPNsense with GeoIP filtering and CrowdSec, and I thought it was in decent shape.
It wasn’t. Jellyfin was handing out full movies to anyone who asked, with no login at all.
The problem
Jellyfin’s API has a handful of endpoints that don’t check who is asking. The worst ones are the media streams themselves. In the Jellyfin 12.4 source, these have no authorization requirement at all:
/Videos/{id}/streamand/Videos/{id}/stream.{container}: the whole video file, or a remux of it/Audio/{id}/streamand/Audio/{id}/stream.{container}: the same for music/LiveTv/LiveRecordings/{id}/streamand/LiveTv/LiveStreamFiles/{id}/stream.{container}: recordings in progress and live TV streams- the legacy HLS segment routes under
/Videos/{id}/hls/...and/Audio/{id}/hls/..., which don’t even show up in the API documentation
Send a request to the first one with a valid item ID and no token at all, and Jellyfin happily answers with 206 Partial Content and starts streaming the file.
This isn’t a secret. It’s been tracked upstream for years. jellyfin/jellyfin#5415, “Collection of potential security issues in Jellyfin”, lists unauthenticated video and audio streams among a pile of other items. The usual response is “you need the item ID, and those are random.” That’s where it got worse for me.
Item IDs aren’t secrets. I went and read the source. An item’s ID is an MD5 hash of the item’s type name and its lowercased file path, as Jellyfin sees it inside its container. It is not random.
To be fair, guessing a path isn’t trivial: one wrong character and the hash is wrong. But it’s a search over a fairly small space, not a secret. Jellyfin’s own Docker docs mount media at /media; the popular linuxserver.io image uses /data/movies and /data/tvshows. Jellyfin’s naming guide asks for Movie Name (Year)/Movie Name (Year).mkv, optionally with a public IMDb or TMDb ID in brackets. For one specific title, the combinations of mount point, naming style and file extension number in the hundreds, not the millions. Libraries that follow no convention are much harder to guess.
The more realistic way to get an ID is that it leaks. It’s in every stream URL, so it ends up in browser history, proxy logs, and anything someone copies and shares. And once someone has an ID, the login page and the 2FA aren’t in the way, because the stream endpoint never asks.
I confirmed it against my own server from outside: no token, real item, 206. That was enough to make this the next project.
Why not just wait for a fix?
Because the fix has to come from Jellyfin’s auth model, and that’s a big change. Clients authenticate in at least three different ways (an Authorization header, an X-Emby-Token header, or an ApiKey/api_key query parameter in the URL), and some Live TV requests carry no token at all. Locking every endpoint down server-side means breaking clients until they all catch up. I understand why it hasn’t happened yet. But my server is exposed today.
What I could do today is put something in front of Jellyfin that refuses media requests without a valid token.
Step one: watch before you block
Before blocking anything, I needed to know how each of my clients actually sends its token. If I guessed wrong, I’d lock out the TV in the living room. So for a day I mirrored the media requests to a small audit logger that recorded how each request was authenticated (header or query string, which header, which client) without ever logging the token itself.
The results:
- The Android app sends an
Authorizationheader. - The LG TV app, the web browser and the Xbox put
ApiKey=in the URL. - Live TV’s video segments carry no token at all. They’re fetched from a legacy HLS path that Jellyfin serves anonymously.
That last one matters. If I’d just required a token everywhere, Live TV would have died on every device. I went back to the Jellyfin source to see how risky it was to leave that path open. Those segment file names are hashes that include a random per-session ID, so you can’t compute them the way you can compute item IDs. You’d need a URL from a live session. That’s a narrow enough hole that I was comfortable exempting exactly that path shape, and nothing broader.
Step two: the gate
My setup is a k3s cluster with Traefik as the ingress, behind nginx on OPNsense as my edge router. Traefik has a forwardAuth middleware: before passing a request through, it asks another service “should this go through?”, and that service answers 200 (yes) or 401 (no).
So I wrote a small service, about 200 lines of Python, called jellyfin-media-auth. For every request to /videos/, /audio/ or /livetv/ it:
- Finds the token wherever the client put it (any of the headers, or the query string).
- Asks Jellyfin who it belongs to by calling
/Users/Mewith that token. Jellyfin answers 401 for a bad token, so Jellyfin itself stays the source of truth. No separate user list, no second password. - Caches the answer for 5 minutes, so a two-hour movie doesn’t mean thousands of lookups.
- Returns 200, or 401 if the token is missing or invalid.
It also has a few guards:
- Rate limits: a per-device limit for valid tokens (well above the busiest real client I measured), and a much tighter per-IP limit for bad tokens. An IP that keeps sending garbage gets a 401 without the service even asking Jellyfin.
- Real client IPs: the service uses the right-most
X-Forwarded-Forentry that isn’t my own proxy. The left-most entry is whatever the client typed, and I proved that by forging it. - No tokens in logs: it logs every decision, but never the token. The decision log is what the alerts read.
- A NetworkPolicy: other pods in the cluster can’t go around the gate and talk to Jellyfin directly.
Then I tried to get past it. Case tricks (/VIDEOS/), URL-encoding (/%56ideos/), double slashes, /x/../Videos/, fake tokens in the URL and in headers, forged X-Forwarded-For headers. Everything came back 401, or 404 where the path no longer meant anything. Then the real clients: the TV and the phone each played direct, transcoded, and Live TV through the gate.
Step three: make it noisy and make it stick
A 401 is fine, but someone hammering the gate should be noticed and slowed down. Three pieces did that:
- Alerts. Loki rules on the gate’s decision log fire when one of my devices keeps getting refused (usually a stale token after a sign-out), when rejections spike, when the gate can’t reach Jellyfin, or when a device with a valid token is stuck in a request loop.
- Timed bans. OPNsense nginx logs every 401 with the real client IP, and CrowdSec was already reading those logs. I added a scenario that bans an IP sending repeated bad tokens to media paths. The ban is short and escalates: 15 minutes, then 30, and so on, capped at a day.
- A learned-IP exemption. A ban blocks a whole IP, which at a friend’s house means every device in their home. So any public IP that has played something with a valid token in the last week is exempt from this one ban. Only from this one: every other CrowdSec scenario still applies to it, and the token check still applies to everyone behind it. I deliberately did not use CrowdSec’s global allowlist for this. Allowlisting a cellular carrier’s shared IPv4 address would exempt thousands of strangers from everything.
Two smaller things I fixed on the way:
- Clients put long-lived tokens in URLs, which means they end up in access logs. I added scrubbing in front of Loki so tokens and API keys are redacted before they’re stored.
- There’s now a checklist that runs on every Jellyfin upgrade: do the clients still play, does a request with no token still get a 401, has the new version added any anonymous media endpoints, and does Live TV still work.
What this does not fix
I want to be honest about the limits:
- Any valid account can stream any item. This is authentication, not per-item authorization.
- Other anonymous endpoints are still open. Artwork by item ID, the public user list and a few others. I gated the expensive and sensitive part: the media itself.
- There may be routes I haven’t found. The gate covers known paths. That’s why the upgrade checklist exists.
- A revoked token keeps working for up to 5 minutes because of the cache.
- It fails closed. If the gate or Jellyfin is down, media stops playing. That’s the trade I wanted.
- The Live TV exemption depends on segment names being unguessable. They are today, per the source code. A leaked segment URL still works for that session.
If your setup is simpler
Most people running Jellyfin don’t have a k3s cluster, CrowdSec and Loki. Here’s what I’d do, from best to good enough.
1. Don’t expose Jellyfin to the internet. This is the real answer. Put WireGuard or Tailscale on the server and on your devices, and reach Jellyfin over the tunnel. Nothing to gate, because nobody outside can reach it. It’s the right call for most households. Its weak spot is TVs and game consoles that can’t run a VPN client.
2. If you must expose it, gate the media paths at your reverse proxy. You don’t need a separate service for the basic version. nginx’s auth_request can ask Jellyfin directly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
upstream jellyfin { server 127.0.0.1:8096; }
server {
# ... your listen / server_name / TLS settings ...
# Media paths: only for requests carrying a token Jellyfin accepts.
location ~* ^/+(videos|audio|livetv)/ {
auth_request /_jellyfin_auth;
proxy_pass http://jellyfin;
}
location / {
proxy_pass http://jellyfin;
}
location = /_jellyfin_auth {
internal;
proxy_pass http://jellyfin/Users/Me;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# Header tokens are forwarded as-is; this covers ?ApiKey= and ?api_key=.
# nginx matches $arg_ names case-insensitively and skips an empty header.
proxy_set_header X-Emby-Token "$arg_apikey$arg_api_key";
}
}
I tested the rejection side of this against a real Jellyfin 12.4: no token, fake ApiKey, fake api_key, a fake Authorization header, and //VIDEOS/ all get a 401 from nginx before Jellyfin ever sees the request. The allow side is the same /Users/Me check my gate makes in production, but test it with your own clients before you rely on it, the same way I did with my audit day. Some caveats:
- Live TV will break if your clients fetch segments without a token, like mine did. You’d need a separate, narrow
locationthat lets only the segment path shape through. - There’s no caching. Every media request calls
/Users/Me. For a few household streams that’s fine. If it isn’t, nginx can cache the auth subrequest keyed on the token. - Caddy and Traefik can do the same thing with
forward_authandforwardAuth. Point either one at/Users/Methe same way.
3. Rate-limit and watch. Even without CrowdSec, fail2ban on your proxy’s access log can ban IPs that pile up 401s on /videos/. Keep the ban short; you’ll lock out your own family eventually.
4. Keep tokens out of your logs. If you ship proxy logs anywhere, scrub api_key, ApiKey and Token="..." values first.
Wrapping up
None of this should be necessary. A media server should refuse to stream media to someone who isn’t logged in, and I hope Jellyfin’s auth model gets there. Until then, the fix is to stop trusting that “they’d need the item ID” protects you. An ID is a hash of a file path, and it shows up in every stream URL. Check your own server: grab an item ID from the web UI, open a private window, and request /Videos/<that id>/stream with no token. If it starts downloading, so can anyone else.