CSP for Contentful and Headless CMS: A Real-World Fix

Table of Contents

Headless CMS setups make CSP harder in a very specific way: your page looks static, but the browser is talking to half a dozen origins you forgot about.

I’ve seen this pattern a lot with Contentful, Next.js, and marketing teams that add analytics, consent banners, preview mode, embedded videos, and rich text assets over time. The app starts simple. Then one day you flip on a strict CSP and half the site breaks.

Here’s a real-world case study based on a common stack:

  • Frontend: Next.js
  • CMS: Contentful
  • Media: Contentful Images
  • Analytics: Google Tag Manager and Google Analytics
  • Consent: Cookiebot
  • Preview mode for editors
  • A few embeds and API calls

The goal wasn’t just “make CSP pass.” The goal was to stop using a vague allowlist that kept growing every sprint.

The starting point: a permissive, fragile CSP

A lot of teams begin with something like this:

Content-Security-Policy:
  default-src 'self' https:;
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https:;
  style-src 'self' 'unsafe-inline' https:;
  img-src 'self' data: https:;
  font-src 'self' data: https:;
  connect-src 'self' https: wss:;
  frame-src 'self' https:;
  object-src 'none';

This “works,” but only because it barely restricts anything. If a malicious script sneaks onto the page, this policy is not going to help much.

The team I worked with had added this broad policy after repeated breakages from:

  • Contentful delivery API calls
  • Contentful preview API calls
  • image optimization pulling from images.ctfassets.net
  • GTM and GA scripts
  • Cookiebot consent UI
  • editor preview and live update websockets
  • random embeds inside CMS rich text

That last one is where things usually go sideways. Headless CMS content is code-adjacent. It’s not executable JavaScript by default, but it absolutely controls what your frontend tries to load.

A better reference point from the real world

A good production CSP is usually more explicit. Here’s the real CSP header published by headertest.com:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZjJjZmZiNzMtYmVkMy00OGMwLTgyODEtNGFkNTUyYzVlODA5' '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'

A few things I like here:

  • script-src uses a nonce plus 'strict-dynamic'
  • object-src 'none' is set
  • base-uri 'self' is set
  • frame-ancestors 'none' is set
  • sources are named intentionally, not just https:

That’s the direction I’d push a Contentful site too.

What actually broke when we tightened CSP

When we replaced the permissive policy with a strict one, we saw these console errors:

  • Contentful images blocked
  • preview API requests blocked
  • GTM bootstrap script blocked
  • Cookiebot iframe blocked
  • inline hydration helpers blocked
  • editor preview websocket blocked
  • YouTube embeds from rich text blocked

This is the normal part. CSP work is mostly inventory.

For a Contentful setup, I usually map dependencies like this:

Content sources

  • cdn.contentful.com for delivery API
  • preview.contentful.com for preview API
  • images.ctfassets.net for media
  • downloads.ctfassets.net if downloadable assets are used

App/runtime sources

  • your own site origin
  • your API origin
  • websocket origin for preview/live updates if applicable

Third-party sources

  • GTM
  • GA
  • Cookiebot
  • video/embed providers explicitly allowed by your renderers

If your rich text renderer allows arbitrary iframes from CMS content, you don’t have a CSP problem. You have a content governance problem.

The “before” policy in the actual app

This was close to what the team had in production:

const csp = `
  default-src 'self' https:;
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https:;
  style-src 'self' 'unsafe-inline' https:;
  img-src 'self' data: blob: https:;
  font-src 'self' data: https:;
  connect-src 'self' https: wss:;
  frame-src 'self' https:;
  object-src 'none';
`.replace(/\s{2,}/g, ' ').trim()

Problems:

  • https: everywhere defeats the point of source control
  • 'unsafe-inline' and 'unsafe-eval' in scripts are doing too much damage
  • no base-uri
  • no form-action
  • no frame-ancestors
  • no distinction between delivery and preview environments
  • no strategy for CMS-controlled embeds

The “after” policy

We split the policy by environment. Production users do not need preview API access. Editors in preview mode do.

Here’s the production policy:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://consent.cookiebot.com https://*.cookiebot.com;
  img-src 'self' data: blob: https://images.ctfassets.net https://downloads.ctfassets.net;
  font-src 'self';
  connect-src 'self' https://cdn.contentful.com https://www.google-analytics.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com https://www.youtube-nocookie.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests;

And preview mode:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://consent.cookiebot.com https://*.cookiebot.com;
  img-src 'self' data: blob: https://images.ctfassets.net https://downloads.ctfassets.net;
  font-src 'self';
  connect-src 'self' https://cdn.contentful.com https://preview.contentful.com wss://preview.contentful.com https://www.google-analytics.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com https://www.youtube-nocookie.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests;

That fixed the app without falling back to https: wildcards.

Why this version is better

A few opinionated takes:

1. Don’t give Contentful preview access to everyone

If public visitors never use preview content, don’t allow preview.contentful.com in your public policy. Separate it.

2. Rich text embeds need an allowlist

If editors can paste arbitrary embed code, you’ll end up bloating frame-src and maybe script-src. I prefer transforming known embed URLs into safe components.

Bad:

<div dangerouslySetInnerHTML={{ __html: entry.embedCode }} />

Better:

const ALLOWED_EMBEDS = new Set([
  'www.youtube-nocookie.com',
  'player.vimeo.com',
])

export function SafeEmbed({ src, title }) {
  const url = new URL(src)
  if (!ALLOWED_EMBEDS.has(url.hostname)) {
    return null
  }

  return (
    <iframe
      src={src}
      title={title}
      loading="lazy"
      referrerPolicy="strict-origin-when-cross-origin"
      allowFullScreen
    />
  )
}

This keeps CMS flexibility without turning CSP into mush.

3. Use nonces for scripts if your framework supports it

If you’re on Next.js and injecting any inline script for bootstrapping, use a nonce. Don’t default to 'unsafe-inline' for scripts just because it’s easy.

4. style-src 'unsafe-inline' is often the last compromise

I don’t love it, but in real apps it’s common, especially with consent tooling and framework-generated styles. I try to remove it eventually, but I’d rather keep script execution locked down first.

Example Next.js header setup

Here’s a simple pattern for generating a nonce and attaching CSP in middleware or server logic.

import crypto from 'node:crypto'

export function buildCsp(nonce, isPreview = false) {
  const connectSrc = [
    "'self'",
    'https://cdn.contentful.com',
    'https://www.google-analytics.com',
    'https://*.google-analytics.com',
    'https://*.googletagmanager.com',
    'https://*.cookiebot.com',
  ]

  if (isPreview) {
    connectSrc.push('https://preview.contentful.com', 'wss://preview.contentful.com')
  }

  return [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com`,
    `style-src 'self' 'unsafe-inline' https://consent.cookiebot.com https://*.cookiebot.com`,
    `img-src 'self' data: blob: https://images.ctfassets.net https://downloads.ctfassets.net`,
    `font-src 'self'`,
    `connect-src ${connectSrc.join(' ')}`,
    `frame-src 'self' https://consentcdn.cookiebot.com https://www.youtube-nocookie.com`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `object-src 'none'`,
    `upgrade-insecure-requests`,
  ].join('; ')
}

export function getNonce() {
  return crypto.randomBytes(16).toString('base64')
}

Then pass the nonce into any inline scripts your app genuinely needs.

What changed after rollout

After moving to the stricter policy:

  • blocked inline script execution unless nonce-approved
  • removed broad https: and wss: source allowances
  • kept public traffic away from preview endpoints
  • reduced iframe sprawl from CMS content
  • made new third-party additions obvious during testing

That last point matters most. A good CSP turns surprise dependencies into visible failures. That’s exactly what you want.

Practical advice for Contentful teams

If you’re setting CSP for a headless CMS stack, I’d do this in order:

  1. Inventory every external origin loaded in production
  2. Split public and preview policies
  3. Lock down script-src first with nonces
  4. Restrict Contentful to the exact domains you use
  5. Replace arbitrary embeds with safe components
  6. Add base-uri, form-action, and object-src 'none'
  7. Run in report-only first if the app is messy

If you want ready-to-use policy patterns, the official CSP documentation is still the source of truth, and https://csp-examples.com is handy when you want working examples without reinventing the syntax.

Headless CMS architecture doesn’t make CSP impossible. It just forces you to be honest about what your frontend actually trusts. That’s a good thing.