Security Headers Configuration Guide: CSP, HSTS, X-Frame
TL;DR: Security headers are HTTP response headers that tell the browser how to treat your site: which scripts are allowed to run, whether the connection must stay encrypted, and whether the page can be embedded in someone else's frame. The four that matter most are Content-Security-Policy (CSP), Strict-Transport-Security (HSTS), X-Frame-Options, and X-Content-Type-Options. CSP is the one most likely to break something if you get it wrong, so build it in report-only mode first, watch what it would have blocked, then switch to enforcing. HSTS is safe to enable once every subdomain genuinely serves HTTPS, since it tells browsers to refuse HTTP entirely for a set period. Vercel, Netlify, and Cloudflare Pages each have a slightly different place to configure these, covered below with working examples for each.
What Are Security Headers and Why Do They Matter for a Deployed Site?
A security header is an instruction sent with every HTTP response that tells the browser to enforce a rule the server can't enforce on its own once the page has loaded. The server can refuse to send a malicious script, but it can't stop a browser from executing an injected one after the fact unless it has told the browser, in advance, what is and isn't allowed to run.
That's the gap security headers close. They don't replace input validation or sanitisation; they're a second layer that limits the damage when something slips past the first one, which is exactly the layer that matters most once a site is live and public.
For a site deployed through any of the usual paths (Vercel, Netlify, Cloudflare Pages), adding these headers is configuration, not code. You're not writing security logic; you're telling the platform what to send.
What Does a Content Security Policy Actually Do?
CSP tells the browser which sources of scripts, styles, images, fonts, and other content are allowed to load on the page. Anything not on the allowed list gets blocked, and the browser reports it to the console instead of executing it silently.
A reasonable starting policy for a typical site with some third-party scripts:
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'self'
Read that as a set of allow-lists, one per resource type. default-src 'self' is the fallback for anything not explicitly listed: only the site's own origin. script-src then narrows scripts specifically to the site itself plus one named third party. Anything else attempting to load a script gets blocked and logged.
How Do You Write a CSP That Doesn't Break Your Site?
Ship it in report-only mode first. This header evaluates the policy and logs violations without actually blocking anything, which is exactly what you want while you're still finding out what the policy would break.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report
Run the site through its normal traffic for a few days in this mode, check the browser console and any reports coming into /csp-report, and add sources to the policy as legitimate ones show up blocked. Analytics scripts, embedded video players, font CDNs, and payment widgets are the most common things people forget to list on the first pass.
Once the report-only version runs clean for a realistic stretch of traffic, switch the header from Content-Security-Policy-Report-Only to Content-Security-Policy and you're enforcing rather than just logging.
What Is HSTS and When Should You Enable It?
HTTP Strict Transport Security tells the browser to never attempt an HTTP connection to your domain again, for a set period, even if a link or bookmark points at the http:// version. The browser rewrites it to https:// before making the request at all.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-age=63072000sets the enforcement window to two years, in seconds.includeSubDomainsextends the rule to every subdomain, not just the one that sent the header, so confirm every subdomain actually serves HTTPS before adding this.preloadopts the domain into browser vendors' built-in preload lists, which enforce HTTPS before the browser has ever even talked to your site once. This is close to irreversible in practice (removal from the preload list takes months), so only add it once you're confident HTTPS is permanent across the whole domain and every subdomain.
Start without preload, confirm everything behaves for a few weeks, then add it.
What Does X-Frame-Options Protect Against?
X-Frame-Options controls whether your page can be loaded inside an <iframe> on another site, which is the mechanism behind clickjacking: an attacker embeds your page invisibly under their own, and tricks a visitor into clicking something on your site while believing they're clicking something on the attacker's.
X-Frame-Options: SAMEORIGIN
SAMEORIGIN allows your own site to frame its own pages (useful for internal previews or admin tools) while blocking every other origin from doing the same. DENY blocks framing entirely, including by your own site. CSP's frame-ancestors directive does the same job with more granularity (you can allow specific origins rather than just same-origin or none) and is the modern replacement, but shipping both together is harmless and covers older browsers that don't respect frame-ancestors.
What Other Headers Should You Add?
Three more are worth the five minutes it takes to add them:
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Content-Type-Options: nosniffstops the browser from guessing a file's type based on its content rather than trusting the declaredContent-Type, which closes off a class of attack where a malicious file disguised as an image gets executed as a script.Referrer-Policycontrols how much of your URL gets sent as the referrer when a visitor clicks a link away from your site.strict-origin-when-cross-originis a sensible default: full path on same-origin navigation, just the origin on cross-origin.Permissions-Policyexplicitly disables browser features the site doesn't use. If you're not using the camera, microphone, or geolocation APIs, say so, which prevents an injected script from requesting them even if CSP has a gap somewhere.
How Do You Set Headers on Vercel?
Add a headers array to vercel.json at the project root:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" },
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'" }
]
}
]
}
This applies to every route matched by the pattern, and Vercel supports multiple entries with different source patterns if certain routes need different rules. If the project also relies on environment-specific values in headers, that pairs with the setup in vercel-environment-variables-guide, and the initial connection is covered in connect-vercel-claude.
How Do You Set Headers on Netlify?
Netlify reads headers from a _headers file in the publish directory, or from netlify.toml:
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-Content-Type-Options = "nosniff"
Strict-Transport-Security = "max-age=63072000; includeSubDomains"
Content-Security-Policy = "default-src 'self'; script-src 'self'"
Changes to netlify.toml take effect on the next deploy, so if headers need to go live without a full code change, a build hook triggered rebuild is enough; see netlify-build-hooks-automation for wiring that up, and connect-netlify-claude for the initial setup.
How Do You Set Headers on Cloudflare Pages?
Cloudflare Pages uses a _headers file in the build output directory, with the same syntax Netlify popularised:
/*
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=63072000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self'
If the site is built with Astro, the _headers file goes in the public directory so it gets copied into the build output unchanged; the full deployment path is in astro-cloudflare-pages-guide, and the general static site setup is in deploy-static-site-cloudflare-pages.
How Do You Test Your Headers After Deployment?
Check headers directly rather than trusting that the config file did what you expected:
curl -sI https://example.com | grep -i "content-security-policy\|strict-transport\|x-frame\|x-content-type"
This returns exactly what the browser receives, which is the only reliable way to confirm a header is actually present, correctly cased, and not silently overridden by a platform default. Run it after every change to the header configuration, not just the first time.
Frequently Asked Questions
Will adding a Content Security Policy break my site?
It can, if you enforce it before checking what it blocks. Any script, font, or embed loaded from a source not on your allow-list stops working the moment CSP enforces. Avoid this by shipping Content-Security-Policy-Report-Only first, watching the violations that get logged for a few days of real traffic, and only switching to the enforcing header once the report-only version runs clean.
What's the difference between X-Frame-Options and CSP's frame-ancestors?
They do the same job, controlling whether your page can be embedded in a frame on another site, but frame-ancestors is more flexible: it can allow a specific list of origins rather than just same-origin or none at all. X-Frame-Options is older and more widely supported by legacy browsers. Sending both together is safe and common practice; browsers that understand frame-ancestors prefer it, and older ones fall back to X-Frame-Options.
Is HSTS safe to enable on every site?
Only once you're certain HTTPS is fully and permanently in place, including on every subdomain if you use includeSubDomains. HSTS tells the browser to refuse plain HTTP for the duration of max-age, and that instruction is cached by the browser regardless of what the server does afterward. Enabling it on a domain that still has an HTTP-only subdomain, or on a site that might need to fall back to HTTP for some reason, will break access to that subdomain for the full cache period.
How do I check what headers a live site is actually sending?
Run curl -sI against the URL and inspect the response headers directly, rather than relying on browser dev tools alone, since some headers are consumed by the browser before you can inspect them in certain views. This also confirms the platform (Vercel, Netlify, or Cloudflare Pages) is actually applying the configuration you wrote, rather than serving a cached response from before the change deployed.