Missing frame-src in CSP Blocks YouTube, Kills Watch Logs
This article may contain affiliate links. Its content is not affected by advertising.
In short
Without frame-src in the CSP, default-src 'self' blocks every YouTube embed even with script-src allowing the domain, and window.YT never loads either, so watch-time logging dies silently with it.
Conclusion
Without a frame-src directive in the CSP, default-src 'self' blocks the iframe embed itself, so no YouTube video plays at all — even with script-src allowing the YouTube domain. And if script-src doesn’t allow that domain either, the IFrame Player API script can’t load, so window.YT — which the player depends on — never gets defined. That means playback isn’t the only thing that breaks: the watch-time logging that monitors playback state and reports it also stops, silently. The fix was adding the exact domains YouTube actually uses to three separate directives: script-src, img-src, and frame-src.
Symptom
The CSP for a learning-video platform was defined in one place, securityHeaders in next.config.ts. Before the fix it looked like this:
const securityHeaders = [
// ...
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
`connect-src 'self' https://*.supabase.co wss://*.supabase.co`,
"img-src 'self' data:",
"font-src 'self'",
"frame-ancestors 'none'",
].join('; '),
},
]
There is no frame-src directive at all. CSP falls back to default-src for any directive that isn’t specified, so an unset frame-src is equivalent to frame-src 'self'. Every <iframe> embedding YouTube was blocked in production without exception — confirmed in production on 2026-08-16.
Cause
This CSP wasn’t written with video embedding in mind from the start — not a single YouTube domain was listed anywhere. The impact wasn’t limited to display. The VideoPlayer component depends on the YouTube IFrame Player API, and it’s built to wait for window.YT to become available before constructing the player at all.
if (window.YT?.Player) {
attachPlayer()
} else {
const prev = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => { prev?.(); attachPlayer() }
if (!document.getElementById('yt-iframe-api')) {
const s = document.createElement('script')
s.id = 'yt-iframe-api'
s.src = 'https://www.youtube.com/iframe_api'
document.head.appendChild(s)
}
}
This dynamically inserts a script tag that loads https://www.youtube.com/iframe_api, but the script-src before the fix was just 'self' 'unsafe-inline' 'unsafe-eval' — no external domain allowed at all. So the iframe_api script itself gets blocked as a CSP violation, and window.YT never gets defined. Since VideoPlayer only assembles the player once window.YT exists, and only then starts measuring watch time and sending heartbeats, fixing frame-src alone gets the video showing but leaves watch tracking still dead. Both directives have to be in place together before embedding and API-based watch tracking both work.
The fix
The exact domains YouTube actually uses were added individually to three places: script-src, img-src, and frame-src.
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://s.ytimg.com",
"style-src 'self' 'unsafe-inline'",
`connect-src 'self' https://*.supabase.co wss://*.supabase.co${devConnectSrc}`,
"img-src 'self' data: https://i.ytimg.com",
"font-src 'self'",
"frame-src https://www.youtube.com https://www.youtube-nocookie.com",
"frame-ancestors 'none'",
Breaking that down:
script-srcgainedhttps://www.youtube.com(where the IFrame API itself is served from) andhttps://s.ytimg.com(additional scripts the API loads internally)img-srcgainedhttps://i.ytimg.com(where thumbnail images are served from)frame-srcgainedhttps://www.youtube.comandhttps://www.youtube-nocookie.com(the embed itself)
All of these are scoped to YouTube-specific domains — no wildcard relaxation like unsafe-inline was added. A branch that allows connections to localhost only in development was also added to connect-src, but it’s disabled under NODE_ENV === 'production', so it has no effect on the production CSP.
const devConnectSrc =
process.env.NODE_ENV === 'production' ? '' : ' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*'
Preventing a repeat
The CSP lives in one place in next.config.ts, and adding the directives themselves was straightforward. What delayed catching this is that a CSP violation isn’t a build error and isn’t a TypeScript type error — it only shows up when you actually play a video in a browser and check the console. Even when the embed fails, the <iframe> element itself stays in the DOM, so a glance at the screen doesn’t distinguish “the video isn’t showing” from “it’s blocked by CSP.” Verifying the fix required the same real-browser check: zero CSP errors in the console, window.YT resolving to an object, and the player and thumbnail actually rendering — all three, confirmed on an actual device.
Frequently asked questions
Q1What happens to YouTube embeds when frame-src is missing?
The browser falls back to default-src 'self', so every iframe embed of YouTube is blocked. The console shows a CSP error like "Framing 'https://www.youtube.com/' violates ... default-src 'self'" and no video plays.
Q2What happens if script-src doesn't allow YouTube either?
The IFrame Player API script (https://www.youtube.com/iframe_api) itself can't load, so window.YT never gets defined. Fixing frame-src alone won't help — playback-state monitoring and watch-time logging both depend on window.YT.
Q3How did this fix affect local development?
Production CSP was left unchanged. Only in development (NODE_ENV !== 'production') was localhost added to connect-src, for testing against a local Supabase instance. The production policy only gained the new YouTube-related domains.
Environment verified
- Next.js 16.2.10 / React 19.2.4
- Found in production and fixed same day, 2026-08-16
What this article is based on
- TypeScript file lines 1-21commit ef94f1b
- TypeScript file lines 1-33commit e1801b6
- TypeScript file lines 145-156commit e1801b6
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.