The hls.js CORS error means the server delivering your .m3u8 playlist and
its segments isn’t sending an Access-Control-Allow-Origin header that matches
your page. hls.js loads video through fetch/XHR, so — unlike a native
<video src> tag — every manifest and segment is subject to CORS. Fix it on
the origin/CDN: send Access-Control-Allow-Origin, allow the Range header,
and don’t redirect across origins mid-stream.
Why hls.js trips CORS when a plain video tag doesn’t
Point a Safari <video> element at an HLS URL and it plays with no CORS check —
native playback is exempt. hls.js can’t do that on most browsers: it uses Media
Source Extensions, which means it downloads the manifest and each segment itself
via fetch/XHR, appends the bytes to a SourceBuffer, and drives playback in
JavaScript. Anything JavaScript fetches cross-origin goes through CORS. So the
moment your player and your video live on different origins — app.example.com
loading segments from cdn.example.com — the browser demands the right headers.
What the error actually looks like
hls.js surfaces it as a NETWORK_ERROR — usually manifestLoadError (the
.m3u8 failed) or fragLoadError (a segment failed) — while the browser
console shows the real cause:
Access to fetch at 'https://cdn.example.com/out.m3u8' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
The manifest and the segments are separate requests. It’s common for the
.m3u8 to load fine and then every .ts/.m4s segment to fail — same fix,
but the headers have to be on the segment responses too, not just the playlist.
The fix: headers on every file hls.js touches
On the origin / CDN
Send Access-Control-Allow-Origin on the manifest and every segment. Allow Range in Access-Control-Allow-Headers — hls.js uses byte-range requests, which trigger a preflight.
Avoid cross-origin redirects
A 302 from the manifest URL to a different host restarts the CORS check against the new origin. Serve segments from the same host the manifest points to.
Concretely, the responses need at least:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Range, Origin, Accept
A few things that bite people after the headers are set:
- Credentials. If you set
xhrSetupto send cookies (withCredentials = true), you can no longer useAccess-Control-Allow-Origin: *— it must echo the exact origin, and you must addAccess-Control-Allow-Credentials: true. - Signed URLs. If playback uses signed segment URLs, an expired token reads as a load error, not a CORS error — check the token before blaming CORS.
- Wildcard vs exact.
*is fine for public video without credentials; lock it to your origin once you send cookies or auth headers.
The one-line version
CORS lives on the server, not in hls.js — so no player config makes an origin
without Access-Control-Allow-Origin work. Set the headers on the manifest and
the segments, allow Range, keep everything on one host, and the error goes away.
This is exactly the kind of plumbing rehelios handles for you: manifests and segments are served from a global CDN with correct CORS headers and byte-range support by default, so hls.js — or a native player — just works. See how playback and access control are set up, and the concepts behind it in HLS vs DASH and what a video CDN does.