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
| Header | On JSON API? | Why |
|---|---|---|
| Content-Type: application/json | Always | Required — tells clients it is JSON |
| X-Content-Type-Options: nosniff | Yes | Prevents MIME sniffing even on JSON |
| Cache-Control | Yes | Public for published content, private/no-store for drafts |
| CORS headers | Yes, if cross-origin | Your Next.js frontend on a different domain calling the API |
| Content-Security-Policy | No | CSP is for HTML pages — irrelevant on JSON |
| X-Frame-Options | No | For HTML pages that might be iframed — not JSON APIs |
| Strict-Transport-Security | Optional | Fine 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