The Setup We Keep Seeing
We scan a lot of Next.js sites that use Sanity CMS. It's a popular stack, and almost none of them have a Content Security Policy.
That's not entirely surprising. Next.js doesn't add CSP by default. Sanity's docs don't push for it. Most Vercel deployment guides don't mention security headers at all. So teams ship without one and never circle back.
But there's a second problem in these setups that gets even less attention: the /_next/image endpoint. It's meant for image optimization, but the way most Sanity projects configure it, it becomes an open proxy for anything on cdn.sanity.io — not just your images.
We'll cover both problems and how to fix them. If you're not familiar with CSP basics, read our complete guide to Content Security Policy first — this post assumes you know what a directive is.
No CSP: What's Actually at Risk
A typical Sanity-powered site loads from a handful of external origins:
cdn.sanity.io— images and file assetsapi.sanity.io— GROQ query APIapicdn.sanity.io— cached API responses
These are legitimate. Your site needs them. But without CSP, they're not explicitly allowlisted — they're implicitly trusted along with every other origin on the internet. There's a real difference between "we allow cdn.sanity.io" and "we allow anything."
This gets more interesting with how Sanity handles content. Rich text blocks can include embedded content, custom components, and portable text serializers. If any rendering path has a gap — an unsanitized field, a misconfigured serializer, a third-party embed — there's no CSP to catch whatever gets injected. The browser just runs it.
The _next/image Problem
Next.js has a built-in image optimization API at /_next/image. You use the <Image> component, Next.js fetches the original server-side, resizes it, and serves the optimized version. Good for performance, good for Core Web Vitals.
To allow external images, you configure remotePatterns in your config:
// next.config.js — typical Sanity setup
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
},
],
},
};
This tells Next.js: allow optimization for any URL on cdn.sanity.io. And that's the problem. It allows any image from that hostname — not just your project's images.
So this works as expected:
/_next/image?url=https://cdn.sanity.io/images/YOUR_PROJECT/production/abc123.png&w=800&q=75
But so does this:
/_next/image?url=https://cdn.sanity.io/images/SOMEONE_ELSES_PROJECT/production/xyz789.png&w=800&q=75
Any image on cdn.sanity.io — any project, any dataset — gets proxied through your domain. Your server fetches it, processes it, serves it as if it's yours.
What This Actually Costs You
An open image proxy sounds abstract until you think about what happens in practice:
- Bandwidth. Someone discovers your endpoint and starts routing arbitrary Sanity CDN images through it. You pay for the egress.
- CPU and memory. Every
/_next/imagerequest triggers a fetch, decode, resize, and re-encode. Image processing is expensive. Enough requests and your actual users start noticing. - Cache poisoning. If Cloudflare or Vercel's edge network sits in front, proxied images get cached there. Now your CDN is serving content you never asked for, tied to your domain.
- No second line of defense. Without
img-srcin CSP, there's nothing stopping a browser from loading images through the proxy even if they were injected via XSS. CSP would catch this — but you don't have one. - Your domain, someone else's content. Whatever is on the other end of that Sanity CDN URL, your domain is now serving it. That's a liability.
Locking Down _next/image
The fix is in next.config.js. Instead of allowing all of cdn.sanity.io, restrict the pathname to your specific project ID and dataset:
// next.config.js — locked down
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
pathname: '/images/YOUR_PROJECT_ID/production/**',
},
],
minimumCacheTTL: 3600,
},
};
Swap in your actual project ID. If you use multiple datasets, add a pattern for each. Now /_next/image rejects anything that doesn't match — other projects' images get a 400.
A few more things worth doing at the infrastructure level:
- Rate limit the
/_next/imageendpoint at your reverse proxy. Legitimate traffic to this endpoint is predictable. A spike isn't. - Set
minimumCacheTTL(shown above) so the same image doesn't get re-processed on every request.3600is a reasonable starting point. - Watch your server metrics. Unusual CPU or memory usage correlated with
/_next/imagetraffic is worth investigating.
Building a CSP for Next.js + Sanity
Here's a starter policy. Each directive is scoped to what this stack actually needs:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self' 'unsafe-inline';
img-src 'self' cdn.sanity.io data:;
font-src 'self';
connect-src 'self' api.sanity.io apicdn.sanity.io;
media-src 'self' cdn.sanity.io;
frame-src 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
What each one does:
default-src 'self'— the baseline. Only your origin unless overridden.script-src 'self' 'nonce-{RANDOM}'— your scripts plus anything with a valid nonce. No'unsafe-inline', no'unsafe-eval'. See our guide to fixing unsafe-inline for the full nonce setup.style-src 'self' 'unsafe-inline'— a pragmatic starting point. CSS-in-JS and component libraries love inline styles. Tighten this later with nonces or hashes once you're stable.img-src 'self' cdn.sanity.io data:— your images plus Sanity CDN. Thedata:is for base64 LQIP placeholders if you use them.connect-src 'self' api.sanity.io apicdn.sanity.io— GROQ queries and Sanity API calls.frame-src 'none'andobject-src 'none'— shut these down unless you have a specific reason not to.
Deploy this as Content-Security-Policy-Report-Only first. It logs violations without blocking anything. Run it for a week or two, fix what breaks, then switch to enforcing.
The Middleware Approach
In Next.js, the cleanest way to add CSP is through middleware. Here's a minimal version that generates a per-request nonce:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' cdn.sanity.io data:",
"font-src 'self'",
"connect-src 'self' api.sanity.io apicdn.sanity.io",
"frame-src 'none'",
"object-src 'none'",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce);
return response;
}
Then read the nonce in your layout so you can pass it to scripts:
// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({ children }) {
const nonce = headers().get('x-nonce') || '';
return (
<html>
<body>
<Script nonce={nonce} src="/analytics.js" />
{children}
</body>
</html>
);
}
Fresh nonce on every request. Any script without the matching nonce attribute gets blocked.
Quick Checklist
- Restrict
remotePatternsinnext.config.jsto your specific project ID and dataset path - Add
minimumCacheTTLto avoid reprocessing the same images - Rate limit
/_next/imageat the reverse proxy level - Deploy a CSP in
Report-Onlymode first - Scope
img-src,connect-src, andscript-srcto the origins you actually use - Use nonces for
script-srcinstead of'unsafe-inline' - Monitor violation reports for unexpected loads
- Run your site through HeaderTest to see where your headers stand