Shipping CSP around video players always sounds easy until the stream is black, the poster never loads, and the console starts yelling about blocked media, workers, and manifests.
Mux is a good example. The integration itself is usually straightforward. The CSP work around it is where teams burn time, especially when they start with a tight policy and add Mux live streaming later.
I’ve seen this pattern a few times: a team has a clean app with default-src 'self', maybe a nonce-based script-src, and things look great. Then product adds live video. Someone pastes in a Mux player, it works locally, and production immediately blocks half of it.
Here’s a practical before-and-after case study for a developer audience.
The setup
A fictional but realistic app:
- Next.js frontend
- Mux live stream embedded on an event page
- Existing CSP already locked down
- Analytics and consent tooling already present
The team’s existing production header looked conceptually similar to the one exposed by HeaderTest:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-N2IzZjI5NjMtNDM2Yy00MGYyLWEyMTYtY2ExZjE5NTc5MDk2' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
That’s a pretty normal modern baseline:
- strict
script-src - tight
connect-src - no
object-src - no framing by other sites
Then Mux live streaming got added.
Before: the broken CSP
The team dropped in a Mux player component like this:
import MuxPlayer from "@mux/mux-player-react";
export default function LivePage() {
return (
<MuxPlayer
playbackId="YOUR_LIVE_PLAYBACK_ID"
streamType="live"
autoPlay
muted
metadata={{
video_title: "Launch Event",
viewer_user_id: "user-123"
}}
/>
);
}
The page rendered. The player UI appeared. But the stream didn’t start in production.
Console errors looked like this:
Refused to connect to 'https://stream.mux.com/YOUR_LIVE_PLAYBACK_ID.m3u8' because it violates the following Content Security Policy directive: "connect-src 'self' ..."
Refused to load media from 'https://stream.mux.com/YOUR_LIVE_PLAYBACK_ID.m3u8' because it violates the following Content Security Policy directive: "default-src 'self' ...". Note that 'media-src' was not explicitly set, so 'default-src' is used as a fallback.
Refused to load the image 'https://image.mux.com/YOUR_LIVE_PLAYBACK_ID/thumbnail.jpg' because it violates the following Content Security Policy directive: "img-src 'self' data: https:"
That last one is sneaky. At first glance img-src https: should allow it, but in real apps the actual blocked resource is often not the obvious thumbnail URL you expected. Sometimes it’s a generated poster, storyboard sprite, telemetry endpoint, or worker-related fetch. You need to read the exact blocked URL, not guess.
The team’s first attempt to “fix” it was classic panic-CSP:
Content-Security-Policy:
default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
That made the stream work. It also wrecked the security posture they had spent months tightening.
Don’t do that. If your CSP fix looks like turning the policy into soup, you’re not fixing it.
What Mux actually needs
For a normal Mux live player integration, you usually need to think about these categories:
script-srcif you load any Mux-hosted JS directlymedia-srcfor HLS video/audio deliveryimg-srcfor thumbnails and postersconnect-srcfor manifests, segments, telemetry, or API calls depending on your setup- sometimes
worker-srcorchild-srcdepending on the player stack and browser behavior
The exact domains depend on how you integrate Mux:
stream.mux.comfor playbackimage.mux.comfor thumbnails/posters- possibly
*.mux.comif you want room for related endpoints, though I prefer being more explicit first
If you want a refresher on directive behavior and fallback rules, csp-guide.com is a good reference. The fallback from media-src to default-src catches people all the time.
After: a production-ready CSP
Here’s the revised policy the team shipped.
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https: https://image.mux.com;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com https://stream.mux.com https://image.mux.com;
media-src 'self' blob: https://stream.mux.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
worker-src 'self' blob:;
A few choices here were deliberate.
1. They added media-src
This is the big one.
Without media-src, media loads fall back to default-src, which was never intended to allow streaming hosts. Explicitly adding:
media-src 'self' blob: https://stream.mux.com;
made the player behavior predictable.
I included blob: because some player stacks and browser paths use blob URLs internally. If your exact setup doesn’t need it, great, remove it after testing. But for live streaming, I’d rather start with it than spend an afternoon debugging a browser-specific failure.
2. They kept script-src tight
Notice they did not add random Mux domains to script-src because the React package was bundled locally. That matters.
If you install @mux/mux-player-react or a similar package and bundle it with your app, your script-src may not need any Mux domain at all.
That’s better than loading third-party player code from a CDN just because the docs show a script tag.
3. They were explicit in connect-src
connect-src ... https://stream.mux.com https://image.mux.com;
Depending on player behavior, playback may trigger fetch/XHR activity to stream-related endpoints. If you only add media-src, you can still end up with blocked requests.
4. They added worker-src
This one wasn’t required in every browser, but it fixed intermittent failures during testing:
worker-src 'self' blob:;
If your player stack spins up workers through blob URLs, this saves you from weird “works in Chrome, fails in Safari” conversations.
The actual code change
They also cleaned up the player usage. Before, they had a generic video wrapper with no explicit poster handling:
<MuxPlayer playbackId={playbackId} streamType="live" />
After, they made the remote assets obvious:
import MuxPlayer from "@mux/mux-player-react";
export function LiveStream({ playbackId }: { playbackId: string }) {
return (
<MuxPlayer
playbackId={playbackId}
streamType="live"
poster={`https://image.mux.com/${playbackId}/thumbnail.webp?time=0`}
autoPlay
muted
playsInline
metadata={{
video_title: "Live keynote",
player_name: "mux-player-react"
}}
/>
);
}
That helped the team map actual network activity to CSP directives:
- poster URL ->
img-src - HLS manifest/segments ->
media-srcand possiblyconnect-src
Sounds obvious, but making resource origins visible in code saves time.
Nginx example
Here’s a practical Nginx header config based on the fixed policy:
add_header Content-Security-Policy "
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-$request_id' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https: https://image.mux.com;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com https://stream.mux.com https://image.mux.com;
media-src 'self' blob: https://stream.mux.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
worker-src 'self' blob:;
" always;
Use a real cryptographic nonce generator in production, obviously. I wouldn’t use $request_id as a true nonce strategy without checking how it’s produced in your stack.
What changed operationally
After the fix:
- live playback worked in production
- no need to weaken
default-src - no wildcard
* - no
unsafe-eval - no broad Mux allowlisting in
script-src
That’s the difference between “make it work” and “make it work without punching holes in everything else.”
The rule of thumb I use for video CSP
When streaming breaks under CSP, I walk through it in this order:
-
Is the player code local or third-party hosted?
That decides whetherscript-srceven needs changing. -
Where does the media come from?
Add explicitmedia-src. -
Where do posters/thumbnails come from?
Add explicitimg-src. -
Does the player fetch manifests, telemetry, or use workers?
Checkconnect-srcandworker-src. -
Did we fix the exact blocked origin, or did we guess?
Guessing is how CSPs become junk drawers.
For Mux live streaming, the clean fix is usually small. The broken version tends to come from relying on default-src fallback or from overcorrecting with a wildcard policy.
If your player works only after adding default-src * blob: data:, your CSP isn’t “compatible with Mux.” It’s basically off.