Play any HLS stream from your own domain — headers, CORS and all.
Give it a stream URL plus the headers that stream requires, and it hands back an
.m3u8 that plays anywhere. Every playlist, segment and key is served from your
domain, and the original request headers are reattached automatically all the
way down to the last segment.
curl "http://localhost:8080/encode?url=https://example.com/master.m3u8&header=Referer:https://example.com/"{
"url": "http://localhost:8080/proxy/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUu...",
"payload": "eyJ1cmwiOiJodHRwczovL2V4YW1wbGUu..."
}Drop that url into hls.js, VLC, ffmpeg or a <video> tag and it just plays.
A protected HLS stream usually refuses to play in a browser for three reasons:
- It demands headers on every request.
Referer,Origin, a specificUser-Agent— and not just for the playlist, but for all several thousand segments. Browser video players give you no way to attach custom headers to segment requests. - CORS blocks it. The origin never sent
Access-Control-Allow-Origin, so the browser refuses the response even when the request succeeds. - The origin only trusts browsers. Some reject any client that doesn't look like one, right down to the TLS handshake.
hls-proxy sits in the middle and handles all three. Your player only ever talks to your domain, over plain CORS-enabled HTTP, and the proxy does the awkward part upstream.
- Headers propagate automatically. Set them once; every variant playlist, segment, AES key and init segment inherits them.
- Stateless. Everything needed to fetch a resource is encoded in its URL, so there is no session store, nothing to expire, and you can run any number of instances behind a load balancer.
- Segments stream through. Video bytes are never buffered in memory, and
Rangerequests are forwarded so seeking works. - Live and VOD. Live playlists are re-fetched and rewritten on every refresh.
- Handles real-world playlists. Master and media playlists,
EXT-X-KEY,EXT-X-MAP,EXT-X-MEDIA, I-frame streams, low-latencyEXT-X-PART, relative and absolute URLs, inlinedata:keys. - Browser-identical requests. Upstream fetches use a real browser's TLS and HTTP/2 fingerprint, so origins that fingerprint clients still serve them.
- One binary. No runtime dependencies, no config file required.
Download a binary from the releases page:
| Platform | Architecture | Archive |
|---|---|---|
| Linux | x86_64 | hls-proxy-<tag>-x86_64-unknown-linux-gnu.tar.gz |
| Linux | arm64 | hls-proxy-<tag>-aarch64-unknown-linux-gnu.tar.gz |
| macOS | Apple silicon | hls-proxy-<tag>-aarch64-apple-darwin.tar.gz |
| macOS | Intel | hls-proxy-<tag>-x86_64-apple-darwin.tar.gz |
| Windows | x86_64 | hls-proxy-<tag>-x86_64-pc-windows-msvc.zip |
| Windows | arm64 | hls-proxy-<tag>-aarch64-pc-windows-msvc.zip |
Every archive ships a .sha256 checksum. Linux builds target glibc 2.35
(Ubuntu 22.04) for broad compatibility.
Archive names use Rust's
arch-vendor-os-abitarget naming.unknownis just the vendor field for platforms without a single vendor, sox86_64-unknown-linux-gnumeans "64-bit Linux, glibc" — the right choice for almost every Linux server.aarch64is 64-bit ARM.
Or build it yourself — see docs/DEVELOPMENT.md:
cargo build --releaseStart the server:
hls-proxyIt listens on 0.0.0.0:8080 and needs no configuration to work locally.
The /encode endpoint turns a stream and its headers into a playable URL:
curl "http://localhost:8080/encode?url=https://example.com/master.m3u8&header=Referer:https://example.com/&header=Origin:https://example.com"Repeat header as many times as you need. For longer header sets, POST JSON
instead:
curl -X POST http://localhost:8080/encode \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/master.m3u8",
"headers": {
"Referer": "https://example.com/",
"User-Agent": "Mozilla/5.0 ..."
}
}'<video id="video" controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
const hls = new Hls();
hls.loadSource("http://localhost:8080/proxy/eyJ1cmwiOi...");
hls.attachMedia(document.getElementById("video"));
</script>Or from the command line:
ffplay "http://localhost:8080/proxy/eyJ1cmwiOi..."
ffmpeg -i "http://localhost:8080/proxy/eyJ1cmwiOi..." -c copy out.mp4Full endpoint and payload reference: docs/API.md.
All configuration is environment variables. A local .env file is loaded if
present — see .env.example.
| Variable | Default | Meaning |
|---|---|---|
BASE_URL |
(request Host) |
Public origin the rewritten URLs point at, e.g. https://hls-proxy.example.com. Falling back to the request host means local runs need no config. |
BIND |
0.0.0.0 |
Address to bind. |
PORT |
8080 |
Port to listen on. |
DEFAULT_EMULATION |
chrome_137 |
Browser profile used for upstream requests. |
DEFAULT_EMULATION_OS |
windows |
Platform that profile presents as. |
PROXY_URL |
(none) | Route upstream requests through this HTTP/HTTPS proxy, e.g. http://user:pass@host:port. Unset means direct connections. |
PROXY_MODE |
always |
With PROXY_URL set: always proxies everything; fallback goes direct until a host 429s, then proxies just that host. |
SEGMENT_MODE |
proxy |
proxy relays every segment; auto hands segments that work without special headers straight to the player as direct CDN links. |
RUST_LOG |
hls_proxy=info |
Log filter. |
Set BASE_URL when you deploy behind a domain:
BASE_URL=https://hls-proxy.example.com PORT=8080 hls-proxyWhen one provider serves many of your streams, all those requests leaving from a
single IP is what earns you 429 Too Many Requests. Set PROXY_URL and every
upstream fetch — playlists and segments, across all emulation profiles — goes
out through the proxy instead:
PROXY_URL="http://user:pass@proxy.example.com:8080" hls-proxyCredentials in the URL are sent to the proxy as Proxy-Authorization; only the
redacted scheme://host:port is ever logged. A malformed PROXY_URL fails at
startup rather than quietly falling back to direct connections. Only http and
https proxies are supported. To spread load across several IPs, run one
instance per proxy, or put a rotating proxy behind a single PROXY_URL.
For live HLS the playlist is refreshed every few seconds and every segment is on
the critical path, so an always-on proxy hop adds latency to requests that mostly
would have succeeded direct. PROXY_MODE=fallback avoids that: it sends requests
direct and only diverts a host to the proxy once that host actually returns a
429.
PROXY_URL="http://user:pass@proxy.example.com:8080" PROXY_MODE=fallback hls-proxyHow fallback behaves:
- A direct
429is retried once through the proxy, so the viewer still gets the segment — they never see the rate limit. - That host is then routed through the proxy for a cooldown (30s, doubling on
each repeat, capped at 15 min; a
Retry-Afterheader is honored if longer). Other hosts are unaffected. - When the cooldown lapses, a single request probes direct while the rest keep using the proxy. If it succeeds, the host returns to direct; if it 429s, the cooldown escalates. This keeps a recovering host from triggering a thundering herd of concurrent probes that all get rate-limited at once.
Use always (the default) when hiding your origin server's IP from the provider
matters more than per-request latency — in fallback mode the provider sees your
real IP whenever it isn't rate-limiting you.
Often the heavy part of a stream — the segments — sits on an open CDN that needs
no special headers, while only the playlist requires them. Relaying those
segments buys nothing and costs you the whole video's bandwidth twice (CDN → your
server → viewer). SEGMENT_MODE=auto detects this and rewrites the playlist so
the player fetches segments straight from the CDN, leaving your server to
handle only the playlists (a few KB every few seconds):
SEGMENT_MODE=auto hls-proxyHow it decides, per host:
- On the first media playlist for a host, it probes the first segment — a
one-byte
Rangerequest from the same (direct) path the viewer will use, and without the payload's special headers. The verdict is cached per host (~10 min) and single-flighted, so it costs one small request per host, not per segment. - Open (a 2xx that isn't an HTML error page) → segments are emitted as direct absolute CDN URLs. Anything else → segments stay proxied. Every ambiguous signal falls back to proxying.
- Browser players (requests carrying an
Origin) additionally require the CDN to send a compatibleAccess-Control-Allow-Origin, or the segmentfetch()would be blocked; native players (apps, TVs, VLC) don't need this. - Playlists and
EXT-X-KEYdecryption keys are always proxied — they're small, often gated, and the rewritten playlist is the only point of control we keep once segments go direct.
It composes with PROXY_MODE: PROXY_MODE decides how this server reaches the
upstream, SEGMENT_MODE decides whether the player is sent to the CDN at all.
⚠️ Only enable this for providers whose segment URLs are not IP-locked. The probe runs from your server's IP; the viewer fetches from theirs. If a provider binds segment URLs to the requesting IP, the probe succeeds but the viewer's fetch fails — and because direct segments bypass this server, there is no feedback loop for us to detect it. When in doubt, leave it onproxy.
Deployment guides for systemd, Docker, nginx and Caddy: docs/DEPLOYMENT.md.
Every proxied URL has the form /proxy/{token}, where the token is
base64url-encoded JSON describing what to fetch:
{
"url": "https://example.com/live/master.m3u8",
"headers": { "Referer": "https://example.com/" }
}When the proxy fetches a playlist, it rewrites every URL inside it into a new token carrying the same headers. That is the whole trick — context propagates downward automatically, so a segment request arriving an hour later still knows exactly which headers it needs.
player hls-proxy origin
│ │ │
├── /proxy/{master} ───────►│── GET master.m3u8 ────────►│
│ │ + Referer, Origin │
│◄── rewritten playlist ────┤◄── #EXTM3U ────────────────┤
│ (URLs now point here) │ │
│ │ │
├── /proxy/{segment} ──────►│── GET segment.ts ─────────►│
│ │ + the same headers │
│◄── streamed bytes ────────┤◄── video data ─────────────┤
Playlists are small, so they are buffered and rewritten. Everything else is streamed straight through without buffering.
Architecture and design decisions: docs/ARCHITECTURE.md.
There is no authentication. Anyone who can reach the server can encode a payload and use your bandwidth as an open proxy. Keep it on a private network, put an authenticating reverse proxy in front of it, or restrict access at the firewall before exposing it publicly.
The SSRF guard rejects loopback, private, link-local (including cloud metadata
at 169.254.169.254), carrier-NAT and reserved addresses. It understands IPv4
and IPv6, including IPv4-mapped forms like [::ffff:127.0.0.1] that name an
IPv4 address in IPv6 syntax, and it blocks the name localhost. Every redirect
hop is checked too, not just the URL in the token, so an origin cannot answer
with a 302 to a private address.
It is still not a hard boundary. A hostname that resolves to a private address is fetched, because resolution happens inside the HTTP client where this check cannot see it. Restrict egress at the network level if that matters — see docs/DEPLOYMENT.md.
Proxied responses are returned with Content-Security-Policy: sandbox and
X-Content-Type-Options: nosniff. Without them, anyone could point the proxy at
an HTML page and have it served from your domain, which would run script on
your origin.
See docs/DEPLOYMENT.md for ways to lock it down.
| Document | Contents |
|---|---|
| docs/API.md | Endpoints, payload schema, status codes, errors |
| docs/DEPLOYMENT.md | systemd, Docker, nginx/Caddy, TLS, hardening |
| docs/ARCHITECTURE.md | How requests flow, design decisions, limits |
| docs/DEVELOPMENT.md | Building, toolchain setup, tests, releasing |
Pull requests are welcome. CI enforces cargo fmt and cargo clippy -D warnings, so run this before pushing:
cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo testBump version in Cargo.toml, commit it, then push a v* tag —
that tag is what triggers the release build:
git tag v0.1.0 && git push origin v0.1.0Binaries for all six platforms are built on native runners and attached to a GitHub Release automatically. Full checklist, including how to undo a bad tag: docs/DEVELOPMENT.md.
MIT © Hamaad Raza