CSP for Sanity.io and Headless Content

Table of Contents

If you use Sanity.io as a headless CMS, your CSP usually breaks in the same places:

  • Sanity image CDN
  • Sanity API requests
  • live preview or draft content
  • embedded content coming from editors
  • Studio hosting on a separate origin
  • frontend frameworks that sneak in inline scripts

I’ve had to fix this more than once, and the main trap is assuming “headless” means your CSP can stay simple. It rarely does. Content comes from one place, images from another, previews from another, and editors eventually paste in a YouTube embed that blows up your policy.

Here’s the practical reference guide.

The Sanity domains you usually need

For most Sanity-powered frontends, these are the domains that matter:

  • https://cdn.sanity.io — assets and images
  • https://*.apicdn.sanity.io — cached API
  • https://*.api.sanity.io — live API
  • your Studio origin — if hosted separately
  • your preview deployment origin — if draft mode runs elsewhere

A lot of setups only need cdn.sanity.io and one of the API hosts in connect-src.

Minimal CSP for a Sanity frontend

This is the smallest useful starting point for a site that:

  • serves its own JS and CSS
  • fetches content from Sanity
  • renders Sanity-hosted images
  • does not allow arbitrary editor embeds
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https://cdn.sanity.io;
  font-src 'self';
  connect-src 'self' https://*.apicdn.sanity.io https://*.api.sanity.io;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

That already covers a lot of headless brochure sites.

Why both API hosts?

Because Sanity clients may hit either:

  • *.apicdn.sanity.io for CDN-backed reads
  • *.api.sanity.io for live queries, mutations, previews, or authenticated requests

If you know you only use the CDN API in production, tighten it. If you support preview mode, keep both.

Practical production policy for Sanity + analytics

Real sites usually need analytics, consent tooling, and maybe a form endpoint. Here’s a production-style policy modeled after a real-world header pattern like the one from headertest.com, but adapted for Sanity.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com;
  style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https: https://cdn.sanity.io;
  font-src 'self';
  connect-src 'self'
    https://*.apicdn.sanity.io
    https://*.api.sanity.io
    https://www.google-analytics.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 opinions here:

  • I prefer nonces over 'unsafe-inline' for scripts every time.
  • I tolerate 'unsafe-inline' in style-src longer than I’d like, because many consent and UI libraries still force it.
  • img-src https: is broad. It’s convenient, but I only use it if editors can insert arbitrary remote images. If all media is normalized through Sanity, use only https://cdn.sanity.io.

Next.js example with a CSP header

A lot of Sanity sites run on Next.js. Here’s a copy-paste header config for next.config.js.

const csp = `
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://cdn.sanity.io;
  font-src 'self';
  connect-src 'self' https://*.apicdn.sanity.io https://*.api.sanity.io;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
`.replace(/\s{2,}/g, ' ').trim();

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: csp,
          },
        ],
      },
    ];
  },
};

If you need nonce-based CSP with SSR, generate the nonce per request in middleware or your server layer and inject it into both the header and your script tags.

Sanity Studio hosted separately

A common setup is:

  • marketing site at https://www.example.com
  • Studio at https://studio.example.com

Your public site usually does not need to load the Studio. Don’t add Studio domains unless you actually embed it or open preview tooling from the frontend.

If you do embed Studio or an editor-facing iframe, add it explicitly:

frame-src 'self' https://studio.example.com;
connect-src 'self' https://studio.example.com https://*.api.sanity.io https://*.apicdn.sanity.io;

If Studio itself needs a CSP, that’s a separate policy. Studio tends to be much noisier because it uses more scripts, APIs, and sometimes third-party plugins.

Preview and draft mode

Preview mode is where clean CSPs get messy.

Draft previews often require:

  • authenticated API calls to *.api.sanity.io
  • websocket or live update connections in some setups
  • preview deployments on a different host

A preview-friendly policy might look like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://cdn.sanity.io;
  font-src 'self';
  connect-src 'self'
    https://*.apicdn.sanity.io
    https://*.api.sanity.io
    wss://*.api.sanity.io
    https://preview.example.com;
  frame-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Only add wss: if your preview tooling actually needs websockets. Don’t cargo-cult it in.

Embedded content from rich text

This is the part teams forget. Editors don’t just write text. They paste:

  • YouTube videos
  • Vimeo embeds
  • Instagram posts
  • Figma embeds
  • CodePen demos

If your Sanity content model allows arbitrary embeds, your frame-src has to reflect that.

Example:

frame-src 'self' https://www.youtube.com https://player.vimeo.com https://www.instagram.com;

And if you allow remote images in portable text blocks:

img-src 'self' data: https: https://cdn.sanity.io;

I’m not a fan of blanket https: allowances, but sometimes editorial freedom wins. If that’s your setup, at least keep script-src tight so content can’t escalate into script execution.

If you use dangerouslySetInnerHTML

Be honest about your renderer. If your frontend takes HTML from Sanity and injects it directly, your attack surface is bigger than your CSP alone can solve.

CSP helps, but it won’t magically sanitize malicious or broken markup. You still need server-side or render-time sanitization. I’d rather render structured content blocks than raw HTML every day of the week.

If you absolutely must render stored HTML, avoid loosening script-src just to “make embeds work.” Add specific frame-src, img-src, and style-src allowances instead.

A stricter policy for fully controlled Sanity content

If:

  • all images come through Sanity
  • no third-party embeds are allowed
  • no inline scripts are used
  • no remote fonts are used

then use this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https://cdn.sanity.io;
  font-src 'self';
  connect-src 'self' https://*.apicdn.sanity.io;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;

That’s the version I’d ship if the app architecture allows it.

Common breakages and what directive to fix

When a Sanity site breaks under CSP, it’s usually one of these:

Images fail to load

Check:

img-src https://cdn.sanity.io

GROQ fetches fail

Check:

connect-src https://*.apicdn.sanity.io https://*.api.sanity.io

Preview mode fails

Check:

  • connect-src for live API
  • wss: if live preview uses websockets
  • preview deployment origin

Embedded videos fail

Check:

frame-src https://www.youtube.com https://player.vimeo.com

Inline hydration or framework scripts fail

Move to nonce-based scripts:

script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic';

Report-Only first. Always.

For headless content, I strongly recommend starting with Content-Security-Policy-Report-Only before enforcing. Editor-created content tends to reveal dependencies developers forgot about.

Example:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https://cdn.sanity.io;
  connect-src 'self' https://*.apicdn.sanity.io https://*.api.sanity.io;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Watch the violations, then tighten.

Good default templates

If you want ready-made policy patterns for common app shapes, see the examples at https://csp-examples.com.

For Sanity-specific platform details, the official docs are the place to verify current API and asset host behavior: https://www.sanity.io/docs.

My default recommendation

For most Sanity frontends, I’d start here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://cdn.sanity.io;
  font-src 'self';
  connect-src 'self' https://*.apicdn.sanity.io https://*.api.sanity.io;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Then I’d loosen only what the content model actually needs:

  • add frame-src for approved embeds
  • add broader img-src only if editors can use remote images
  • add analytics and consent domains explicitly
  • keep script-src as strict as possible

That’s the real trick with CSP on headless systems: don’t write a policy for the CMS vendor. Write it for the actual content and rendering behavior of your site.