HttpFixerBlogHeaders → Security Headers for Headless CMS APIs — Contentful, Sanity, Strapi
Headers

Security Headers for Headless CMS APIs — Contentful, Sanity, Strapi

Updated April 2026

Reading this? Verify your fix in real-time. Scan your API headers → HeadersFixer

Headless CMS setups have two components: the CMS API (Contentful, Sanity, Strapi) and your frontend (Next.js, Nuxt, SvelteKit). Each needs different headers. Mixing HTML-site headers with API headers causes both security and caching problems.

What headers a JSON API actually needs

HeaderOn JSON API?Why
Content-Type: application/jsonAlwaysRequired — tells clients it is JSON
X-Content-Type-Options: nosniffYesPrevents MIME sniffing even on JSON
Cache-ControlYesPublic for published content, private/no-store for drafts
CORS headersYes, if cross-originYour Next.js frontend on a different domain calling the API
Content-Security-PolicyNoCSP is for HTML pages — irrelevant on JSON
X-Frame-OptionsNoFor HTML pages that might be iframed — not JSON APIs
Strict-Transport-SecurityOptionalFine to add, harmless on API responses

Strapi — configure CORS and remove Server header

# config/middlewares.js
module.exports = [ "strapi::errors", { name: "strapi::security", config: { contentSecurityPolicy: false, // disable CSP on API responses xFrameOptions: false, // not relevant for API hsts: { maxAge: 31536000, includeSubDomains: true, }, }, }, { name: "strapi::cors", config: { enabled: true, origin: ["https://yourfrontend.com", "https://staging.yourfrontend.com"], methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], headers: ["Authorization", "Content-Type"], credentials: false,  // set true if using Strapi auth from browser }, }, "strapi::logger", "strapi::query", "strapi::body", "strapi::session", "strapi::favicon", "strapi::public",
];

Cache-Control for published vs draft content

# Published content — publicly cacheable
Cache-Control: public, max-age=3600, stale-while-revalidate=86400

# Draft/preview content — never cache
Cache-Control: private, no-store

# In Strapi — middleware to set cache headers based on publication state
module.exports = (config, { strapi }) => { return async (ctx, next) => { await next(); if (ctx.status === 200) { const isDraft = ctx.body?.data?.attributes?.publishedAt === null; ctx.set("Cache-Control", isDraft ? "private, no-store" : "public, max-age=3600"); } };
};

Contentful — CORS is handled, add to allowed origins

# In Contentful settings:
# Settings → API keys → your key → allowed origins
# Add: https://yourfrontend.com

# The Content Delivery API (published) is publicly accessible — CORS is open
# The Preview API needs your origin in the allowed list

Sanity — configure CORS in project settings

# sanity.io/manage → your project → API → CORS origins
# Add: https://yourfrontend.com
# Check "Allow credentials" only if using Sanity Studio auth from your frontend

Self-hosted headless API — minimum viable headers

# Express — minimum correct headers for a JSON API
app.use((req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.removeHeader("X-Powered-By");  // do not expose Express version next();
});

# CORS for your frontend only
app.use(cors({ origin: ["https://yourfrontend.com"], credentials: false
}));
Scan your API headers → HeadersFixer
Check if your domain is on the HSTS preload list → HSTS Preload Checker