YouTube embeds look simple right up until your Content Security Policy starts blocking half the player.

I’ve seen this trip up a lot of teams: the page works fine locally, then production sends a strict CSP header and suddenly the iframe is blank, thumbnails don’t load, or the player API silently fails. The fix usually isn’t “disable CSP.” It’s understanding which directives YouTube actually hits, and keeping the allowlist as tight as possible.

The core problem

A YouTube embed is usually an <iframe>, but it doesn’t stop there. The browser may need to load:

  • the iframe itself from youtube.com or youtube-nocookie.com
  • images from i.ytimg.com
  • scripts inside the iframe, governed by YouTube’s own policy
  • optional JS on your page if you use the YouTube Iframe API
  • network calls related to playback, tracking, or consent flows

Your CSP only controls your page and what it can load. It does not rewrite YouTube’s internal CSP. That means your main job is:

  1. allow the iframe to load
  2. allow any resources your page itself needs around the embed
  3. avoid opening up broad wildcard policies you don’t need

The minimum CSP for a basic YouTube iframe

If you’re embedding plain HTML like this:

<iframe
  width="560"
  height="315"
  src="https://www.youtube.com/embed/dQw4w9WgXcQ"
  title="YouTube video player"
  frameborder="0"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
  allowfullscreen>
</iframe>

The key directive is frame-src:

Content-Security-Policy: default-src 'self'; frame-src https://www.youtube.com;

That’s enough for the iframe document itself.

If you use YouTube privacy-enhanced mode, the iframe host changes:

<iframe
  src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
  title="YouTube video player"
  allowfullscreen>
</iframe>

Then your CSP should allow that host instead:

Content-Security-Policy: default-src 'self'; frame-src https://www.youtube-nocookie.com;

If your site uses both embed formats, allow both:

Content-Security-Policy:
  default-src 'self';
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;

Why child-src sometimes shows up

Older CSP examples use child-src for frames. Modern policies should prefer frame-src for iframes specifically. If you want the deeper background on directive behavior and fallback rules, csp-guide.com is a solid reference.

For modern browser support, I’d write:

Content-Security-Policy:
  default-src 'self';
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;

Not this:

Content-Security-Policy:
  default-src 'self';
  child-src https://www.youtube.com;

The policy most teams actually need

A basic embed often also needs thumbnails or preview artwork. Those usually come from i.ytimg.com.

So a more realistic CSP looks like this:

Content-Security-Policy:
  default-src 'self';
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;
  img-src 'self' https://i.ytimg.com;

If your page has no inline scripts and no player API, that may be enough.

If you use the YouTube Iframe API

A lot of developers don’t just embed the iframe; they control playback with JavaScript.

Example:

<div id="player"></div>

<script src="https://www.youtube.com/iframe_api"></script>
<script nonce="{{ .CSPNonce }}">
  let player;

  function onYouTubeIframeAPIReady() {
    player = new YT.Player('player', {
      videoId: 'dQw4w9WgXcQ',
      playerVars: {
        playsinline: 1
      }
    });
  }
</script>

Now your CSP needs to allow:

  • the external script from youtube.com
  • your inline script via nonce or hash
  • the frame host used by the player

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123' https://www.youtube.com;
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;
  img-src 'self' https://i.ytimg.com;

If you want to be stricter, avoid 'unsafe-inline' and use nonces or hashes for the inline bootstrap code. That’s the sane choice on any modern app.

A practical Express example

Here’s a Node/Express setup using helmet:

import express from "express";
import helmet from "helmet";
import crypto from "crypto";

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use((req, res, next) => {
  helmet({
    contentSecurityPolicy: {
      useDefaults: true,
      directives: {
        "default-src": ["'self'"],
        "script-src": [
          "'self'",
          (req, res) => `'nonce-${res.locals.nonce}'`,
          "https://www.youtube.com"
        ],
        "frame-src": [
          "'self'",
          "https://www.youtube.com",
          "https://www.youtube-nocookie.com"
        ],
        "img-src": [
          "'self'",
          "data:",
          "https://i.ytimg.com"
        ],
        "object-src": ["'none'"],
        "base-uri": ["'self'"],
        "frame-ancestors": ["'none'"]
      }
    }
  })(req, res, next);
});

app.get("/", (req, res) => {
  res.send(`
    <!doctype html>
    <html>
      <head>
        <title>YouTube CSP Demo</title>
      </head>
      <body>
        <div id="player"></div>

        <script nonce="${res.locals.nonce}" src="https://www.youtube.com/iframe_api"></script>
        <script nonce="${res.locals.nonce}">
          let player;
          function onYouTubeIframeAPIReady() {
            player = new YT.Player('player', {
              videoId: 'dQw4w9WgXcQ'
            });
          }
        </script>
      </body>
    </html>
  `);
});

app.listen(3000);

That’s a decent baseline. Tight enough to be useful, not so strict that the player breaks instantly.

Don’t confuse frame-src and frame-ancestors

This one causes constant confusion:

  • frame-src controls what your page can embed
  • frame-ancestors controls who can embed your page

If you want to allow YouTube embeds on your page, frame-src is the directive that matters.

If you don’t want anyone framing your own site, use:

frame-ancestors 'none';

That’s exactly what the real CSP header on headertest.com does:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-YjI3ZGQzOWQtNmMxNy00N2UxLWE0MzQtOTM1NzVhN2NiYWM5' '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://u.headertest.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 policy allows a specific framed resource with frame-src, while separately blocking the site itself from being framed using frame-ancestors 'none'.

Common breakages with YouTube embeds

1. You allow youtube.com but use youtube-nocookie.com

This is the classic one.

If your embed URL is:

src="https://www.youtube-nocookie.com/embed/..."

then this policy will fail:

frame-src https://www.youtube.com;

You need:

frame-src https://www.youtube.com https://www.youtube-nocookie.com;

2. Thumbnail images are blocked

If you render custom preview cards like this:

<img src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg" alt="Video preview">

and forget img-src, the image won’t load.

Fix:

img-src 'self' https://i.ytimg.com;

3. The API script is blocked

If you load:

<script src="https://www.youtube.com/iframe_api"></script>

but your script-src only allows 'self', the player API won’t initialize.

Fix:

script-src 'self' https://www.youtube.com;

Or, if you also have inline setup code, use a nonce:

script-src 'self' 'nonce-abc123' https://www.youtube.com;

4. You rely on default-src and forget explicit directives

A lot of people write:

default-src 'self';

and assume that’s enough. It isn’t. frame-src, img-src, and script-src often need explicit values for YouTube-related resources.

A stricter production-ready example

If I were shipping a page with YouTube embeds and no extra third-party junk, I’d start with something like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' https://www.youtube.com;
  style-src 'self';
  img-src 'self' data: https://i.ytimg.com;
  frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com;
  connect-src 'self';
  font-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

A few opinions here:

  • I would not add https: to img-src just because it makes the errors go away.
  • I would not use wildcard hosts unless I had verified they were actually needed.
  • I would not add 'unsafe-inline' to script-src for a single embed bootstrap snippet.

That’s how CSP turns into theater instead of protection.

Debugging blocked embeds

When YouTube breaks under CSP, check these in order:

  1. Browser DevTools Console
    CSP violations are usually explicit.

  2. Network tab
    See which URL got blocked.

  3. Your actual response headers
    Make sure the deployed header matches what you think you sent.

  4. Report-Only mode
    Great for testing before enforcement.

Example:

Content-Security-Policy-Report-Only:
  default-src 'self';
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;
  img-src 'self' https://i.ytimg.com;
  report-to default-endpoint;

If you’re troubleshooting a live policy, always inspect the final header delivered to the browser. Reverse proxies, CDNs, and framework middleware love to “help” in ways that break CSP.

When to use youtube-nocookie.com

If you care about reducing tracking before user interaction, use the privacy-enhanced embed domain:

src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"

That doesn’t magically eliminate all privacy concerns, but it’s generally the better default than standard youtube.com embeds. Your CSP just needs to reflect that host.

The short version

For plain YouTube iframes:

Content-Security-Policy:
  default-src 'self';
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;
  img-src 'self' https://i.ytimg.com;

For embeds plus the YouTube Iframe API:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' https://www.youtube.com;
  frame-src https://www.youtube.com https://www.youtube-nocookie.com;
  img-src 'self' https://i.ytimg.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

That covers most real-world setups without turning your policy into a giant allowlist mess. The trick is staying specific: allow the exact frame host, exact script host, and exact image host you actually use. That’s the difference between a CSP that protects something and one that just looks impressive in a security review.