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 imageshttps://*.apicdn.sanity.io— cached APIhttps://*.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.iofor CDN-backed reads*.api.sanity.iofor 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'instyle-srclonger 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 onlyhttps://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-srcfor live APIwss: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-srcfor approved embeds - add broader
img-srconly if editors can use remote images - add analytics and consent domains explicitly
- keep
script-srcas 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.