Embedding YouTube videos GDPR-compliant — three-tier consent box without a cookie


I wanted to embed a YouTube video in a post. The problem: the classic <iframe src="youtube.com/embed/…"> opens a pipeline to Google before the reader has clicked anything — cookies, fingerprint, IP, all without consent. A textbook GDPR violation.

What I needed:

  1. Before the click: zero requests to Google. No thumbnail, no script, nothing.
  2. Real consent — the click on “Load video” is the consent (Art. 6(1)(a) GDPR).
  3. Alternatives — “show once”, “agree and remember”, or simply “just the link”. No cookie, at most a localStorage entry for the consent decision.

The result is a small Astro component implementing the pattern I cribbed from zeit.de in the wild.

The component

src/components/YouTubeEmbed.astro — core:

---
import { getLocaleFromUrl, t } from '~/i18n/ui';
interface Props { id: string; title?: string }
const { id, title } = Astro.props;
const locale = getLocaleFromUrl(Astro.url);
const privacyHref = locale === 'de' ? '/datenschutz/' : '/en/privacy-policy/';
const youtubeUrl = `https://www.youtube.com/watch?v=${id}`;
---

<figure class="youtube-embed">
  <div class="yt-consent" data-id={id} data-title={title ?? ''}>
    <h3>{t(locale, 'youtube.externalContent')}</h3>
    <p>{t(locale, 'youtube.description')}</p>
    <p>
      {t(locale, 'youtube.serviceName')}
      {title && <><br /><strong>{title}</strong></>}
    </p>
    <div class="yt-consent-buttons">
      <button type="button" data-remember="true">{t(locale, 'youtube.loadVideoRemember')}</button>
      <button type="button">{t(locale, 'youtube.loadVideo')}</button>
      <a href={youtubeUrl} target="_blank" rel="noopener noreferrer">{t(locale, 'youtube.watchOnYouTube')}</a>
    </div>
    <p class="footer">
      {t(locale, 'youtube.consentFooter')}
      <a href={privacyHref}>{t(locale, 'youtube.privacyLink')}</a>.
    </p>
  </div>
</figure>

Plus a small script that swaps the placeholder box for an <iframe> on youtube-nocookie.com when clicked:

<script>
  const CONSENT_KEY = 'yt-consent';

  function buildIframe(id, title, autoplay) {
    const iframe = document.createElement('iframe');
    iframe.src = `https://www.youtube-nocookie.com/embed/${id}?rel=0${autoplay ? '&autoplay=1' : ''}`;
    iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
    iframe.allowFullscreen = true;
    iframe.loading = 'lazy';
    iframe.referrerPolicy = 'strict-origin-when-cross-origin';
    if (title) iframe.title = title;
    return iframe;
  }

  const hasConsent = (() => {
    try { return localStorage.getItem(CONSENT_KEY) === '1'; } catch { return false; }
  })();

  document.querySelectorAll('.yt-consent').forEach((container) => {
    const { id, title } = container.dataset;
    if (!id) return;
    if (hasConsent) {
      container.replaceWith(buildIframe(id, title, false));
      return;
    }
    container.querySelectorAll('button').forEach((button) => {
      button.addEventListener('click', () => {
        if (button.dataset.remember === 'true') {
          try { localStorage.setItem(CONSENT_KEY, '1'); } catch {}
        }
        container.replaceWith(buildIframe(id, title, true));
      });
    });
  });
</script>

Three important details:

  • youtube-nocookie.com instead of youtube.com — the “privacy-enhanced mode”. Only sets data on playback, no personalization cookies.
  • localStorage instead of a cookie — the consent decision is stored locally in the browser only, never sent to a server. Under ePrivacy this counts as “strictly necessary” (it stores the consent itself, not tracking).
  • Auto-replace on hasConsent — anyone who clicked “remember” sees the iframe directly on subsequent visits, but without autoplay (nobody likes videos that start playing unprompted).

i18n

Strings in src/i18n/ui.ts — per locale:

'youtube.externalContent': 'External content',
'youtube.description': 'This is where you\'ll find external content from youtube.com that complements this article.',
'youtube.serviceName': 'YouTube video player (youtube.com)',
'youtube.loadVideo': 'Show once',
'youtube.loadVideoRemember': 'Agree and remember',
'youtube.watchOnYouTube': 'Watch on YouTube',
'youtube.consentFooter': 'I agree to external content being displayed. This may result in personal data being transmitted to third-party providers. More information in our',
'youtube.privacyLink': 'privacy policy',

getLocaleFromUrl(Astro.url) reads the language from the path — done, works DE/EN automatically.

Adjusting the CSP

My strict Caddy CSP blocks every external iframe. The embed therefore needs a targeted exception:

- frame-ancestors 'none';
+ frame-src https://www.youtube-nocookie.com; frame-ancestors 'none';

img-src does not need to be extended because the placeholder doesn’t load any images from Google. No thumbnail, no Google request before consent — that’s the whole point of this construction.

Referrer-Policy: invisible snag

On my first test: Error 153 — Video player configuration error. Even on freely embeddable videos.

Cause: my site-wide Referrer-Policy: no-referrer. YouTube checks the referrer header on embed to verify which domain the iframe comes from. No referrer, no verification → generic Error 153.

Fix at the iframe level: referrerPolicy = 'strict-origin-when-cross-origin'. YouTube now only sees https://adrian-altner.de, no paths — acceptable privacy compromise, verification works.

Privacy policy

Extended with a dedicated section “Video embeds (YouTube)”: description of the three-tier solution, note on the localStorage entry yt-consent=1 (no cookie, local only, consent decision only), legal basis Art. 6(1)(a) GDPR, third-country transfer to the US via standard contractual clauses.

Demo

Here’s how it looks live — no request to Google before you press a button:

Naka Weekend Market, Phuket

Open your browser’s network tab before clicking: you won’t see a single request to google.com, ytimg.com, or googlevideo.com. Only after the click does the iframe load — and even then only from youtube-nocookie.com.

Takeaway

No external consent library, no banner popup, no cookie. One component (~100 lines of Astro), one script block, two CSP lines in Caddy, one entry in the privacy policy. For a few occasional video embeds on a blog, the right order-of-magnitude compromise between “full consent infrastructure” and “just ignore it”.