Guide
Content Security Policy and Security Headers: a Practical Setup Guide
A Content Security Policy (CSP) is an HTTP response header that tells the browser which sources your site may load scripts, styles, images and frames from. Configured properly, it stops injected JavaScript from running. Together with four other headers, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy and Permissions-Policy, it forms the baseline every website should send on every response, not just on the home page.
- A CSP only protects you if
script-srcworks without'unsafe-inline'. Allow the inline scripts you need with a hash or a nonce instead. - Use
frame-ancestorsin the CSP against clickjacking. KeepX-Frame-Optionsonly as a fallback for older browsers, and dropX-XSS-Protectionentirely. - Some features only exist in the HTTP header:
frame-ancestorsand report-only mode have no effect in a meta tag. - Check with GET requests on several URLs, error pages included. Roll out a new CSP in report-only mode and enforce it once the console is clean.
The headers at a glance
What each header does and which value fits.
Go to sectionBuilding the CSP
Directives, hashes, nonces, frame-ancestors.
Go to sectionOur own example
The measured headers of cybersec42.com.
Go to sectionConfiguration
Vercel, Netlify, Apache and nginx.
Go to sectionSecurity headers check
GET instead of HEAD, console, report-only.
Go to sectionCommon mistakes
From unsafe-inline to headers on the home page only.
Go to sectionSecurity headers at a glance
Security headers are instructions your server sends to the browser with every response. They do not change your code; they restrict what the browser is allowed to do with your page. That is both their strength and their limit: they blunt entire classes of attack, such as injected scripts or your page being framed by someone else, but they do not fix a flaw in the application itself.
| Header | Purpose | Typical starting value |
|---|---|---|
Content-Security-Policy | Allowed sources for scripts, styles, images, connections and frames; protects against injected JavaScript and clickjacking | site-specific, see below |
Strict-Transport-Security | The browser only reaches the domain over HTTPS (HSTS) | max-age=31536000; includeSubDomains |
X-Content-Type-Options | The browser does not guess a file type when the declared one does not match | nosniff |
Referrer-Policy | How much of your URL is passed on to other sites | strict-origin-when-cross-origin |
Permissions-Policy | Turns off browser features such as camera, microphone or location | geolocation=(), microphone=(), camera=() |
These values follow the OWASP HTTP Headers Cheat Sheet. For HSTS we start more cautiously, with one year instead of the two years OWASP shows, and without preload. Each header in more detail:
Strict-Transport-Security (HSTS)
HSTS is defined in RFC 6797. max-age sets, in seconds, how long the browser will only contact your domain over HTTPS. includeSubDomains extends that to every subdomain. Only add it once every subdomain has a valid certificate: according to MDN, the browser gives users no way to click through a certificate error on an HSTS host. Two details from the RFC: an HSTS header received over plain HTTP is ignored, and max-age=0 removes the policy, but only once the browser fetches the site over HTTPS again.
The preload token is not part of the RFC. It belongs to the preload list run through hstspreload.org, which browsers ship with. Its requirements are a max-age of at least one year, includeSubDomains and preload. The site warns explicitly that removal takes months to reach users. Only submit your domain if you are sure.
X-Content-Type-Options
The only value is nosniff. According to MDN, the browser then blocks stylesheets not served as text/css and scripts without a JavaScript MIME type. Your server needs to send the correct Content-Type for every file.
Referrer-Policy
Per the W3C specification and MDN, strict-origin-when-cross-origin is what browsers use today when no policy is set: the full URL within your site, only the origin to other HTTPS sites, nothing to HTTP sites. OWASP still recommends setting it explicitly rather than relying on browser defaults. If your URLs carry tokens or search terms, consider something stricter such as no-referrer.
Permissions-Policy
An empty list () disables a feature for your page and every embedded frame; (self) allows it for your own origin only. Disable everything your site does not use. MDN lists the header as not yet available in all widely used browsers, so treat it as an extra layer rather than something to rely on alone.
X-Frame-Options and X-XSS-Protection
X-Frame-Options stops your page from being shown inside someone else's frame. The CSP Level 3 specification states that frame-ancestors overrides this header. MDN describes frame-ancestors 'none' as similar to X-Frame-Options: DENY, but it is more flexible because you can list specific domains that may embed you. Sending X-Frame-Options: DENY as well does no harm and covers older browsers.
X-XSS-Protection, on the other hand, should go. MDN marks it as deprecated and non-standard and warns that the filter could create XSS vulnerabilities in otherwise safe sites. OWASP likewise advises not setting it, or switching it off explicitly with 0, and relying on a CSP without inline JavaScript instead.
Building the Content Security Policy
A CSP is a list of directives separated by semicolons. Each directive names a resource type and the sources allowed for it. default-src covers every type that is not listed separately. A solid starting point for a site without third-party scripts:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'
In the actual header this is a single line; the breaks are only for readability. The directives that matter most:
script-srcis the core of your protection against cross-site scripting (XSS). With'unsafe-inline'in it, any script in the HTML may run, including an injected one.object-src 'none'andbase-uriclose two side doors that can otherwise undermine your script rules. MDN includes both in its strict CSP.form-actioncontrols where forms may submit to.frame-ancestorscontrols who may embed your page, which is your clickjacking protection.
Allowing inline scripts without unsafe-inline
Most sites need a few inline scripts. Instead of 'unsafe-inline', you allow exactly those scripts, in one of two ways:
- Hash: compute the SHA-256 hash of the script content and add it to
script-srcas'sha256-…'. Best for static sites whose scripts only change at build time. - Nonce: the server generates a fresh random value for every response, puts it in the header as
'nonce-…'and addsnonce="…"to every allowed script. Best for server-rendered pages.
MDN calls both approaches a "strict CSP" and the recommended way to mitigate XSS. One useful detail for the migration: when a directive contains a hash or nonce, browsers ignore any 'unsafe-inline' in that same directive, according to both MDN and the CSP specification. Compute the hash over the exact content between the script tags; whitespace and line breaks count:
# inline.js holds exactly the text between <script> and </script>
openssl dgst -sha256 -binary inline.js | openssl base64 -A
Inline event handlers such as onclick="…" cannot simply be allowed this way. Move them into a script file and use addEventListener. For pages that load many scripts dynamically there is 'strict-dynamic': a script allowed by hash or nonce may then load further scripts. MDN points out that this makes the policy weaker if those scripts build script elements from untrusted input.
How we do it on this site
Measured on 28 Oct 2026 with a GET request, curl -s -D - -o /dev/null https://www.cybersec42.com/, and cross-checked on a second URL with a cache buster. Both responses carried the same security headers. The CSP is shortened here; it contains 14 hashes:
content-security-policy: default-src 'self';
script-src 'self' 'sha256-2TkQLINVURTi/RmDX37prpPrw8Zm7dkRfIzOPWxyCG0='
'sha256-3mC/Lyy+nMH9q6flJmQx73Ahi1EFl8by8yb2zwfjzNU=' … (14 hashes);
style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';
connect-src 'self'; frame-ancestors 'none'; base-uri 'self';
form-action 'self'; object-src 'none'
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: geolocation=(), microphone=(), camera=(), interest-cohort=()
The site is static and served by Vercel. The headers live in vercel.json under "source": "/(.*)", so they apply to every file. Before each deploy, a small build script finds every inline script in the bundle, computes its SHA-256 hash and writes the result into script-src. Structured data in <script type="application/ld+json"> is a data block that never executes, so it needs no hash. There are no inline event handlers. If an inline script changes and the build script has not run, the browser blocks it. That is the point: anything not explicitly allowed does not run.
The measurement also shows two deliberate decisions. style-src contains 'unsafe-inline' because the pages use inline style attributes. For styles that is a smaller residual risk than for scripts, but not zero, so it is on our list. And we send X-Frame-Options: DENY alongside frame-ancestors 'none' as a fallback for older browsers.
Configuration examples for Vercel, Netlify, Apache and nginx
Each example sets the headers from the table above. The CSP is the starting point from the previous section; adapt it to your site and test it in report-only mode first.
Vercel: vercel.json
According to the Vercel documentation, you define headers in the headers array, one entry per path pattern under source. /(.*) matches every path.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "geolocation=(), microphone=(), camera=()" }
]
}
]
}
Netlify: _headers
The _headers file goes into the directory Netlify publishes, i.e. your build output. The Netlify documentation notes a limit: these headers only apply to files Netlify serves itself, not to responses from serverless or edge functions and not to proxied content. In those cases the function has to set the headers itself.
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Apache: .htaccess
Apache sets headers through mod_headers. Per the Apache documentation, this only works in .htaccess with AllowOverride FileInfo. Use Header always set: without always, the headers do not reach redirects and error pages.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
nginx
Per the nginx documentation, add_header without always only applies to successful responses and redirects, not to error pages. The bigger trap is inheritance: headers from the server block only apply inside a location block if that location has no add_header of its own. A single add_header Cache-Control … in one location silently drops every security header there. From nginx 1.29.3 you can change this with add_header_inherit merge;; otherwise repeat the headers in each location or pull them in with include.
server {
# ...
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
}
Security headers check: how to measure properly
Only test websites you own or have permission to test. For your own headers, standard tools are all you need.
Step 1: fetch headers with GET, not HEAD
The obvious command, curl -I, sends a HEAD request. RFC 9110 says a server should return the same headers as for GET, but it may omit headers whose values are only determined while generating the content. That is exactly where some applications set their security headers. So measure with a real GET request and discard the body:
curl -s -D - -o /dev/null https://www.example.com/
Then check several kinds of URL in one go, including a subpage, a static file and an error page. A query parameter such as ?cb=1 bypasses cached responses. Compare without it as well, because that is the response your visitors actually get:
for p in / /contact.html /does-not-exist /favicon.ico; do
echo "== $p"
curl -s -D - -o /dev/null "https://www.example.com$p" \
| grep -i -E '^(content-security-policy|strict-transport-security|x-content-type-options|referrer-policy|permissions-policy)'
done
Step 2: read the browser console
Open the developer tools (F12) and reload the page. In the Network tab, the first request shows the response headers that actually arrived. In the Console tab, the browser reports every resource the CSP blocked, along with the directive that was violated. Then click through the important features: submit a form, open the menu, play a video, accept the cookie notice. Many violations only show up on interaction.
Step 3: start a new policy in report-only mode
With Content-Security-Policy-Report-Only, the policy is not enforced; the browser only reports violations in the console and to an endpoint if you provide one. According to MDN, both headers can be present at once: the existing policy stays enforced while the new one is only tested. For reporting, MDN recommends report-to together with the Reporting-Endpoints header. The older report-uri is deprecated in the CSP specification, but MDN advises declaring both until report-to is supported in all browsers.
Reporting-Endpoints: csp="https://www.example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; report-to csp; report-uri https://www.example.com/csp-reports
Keep report-only mode running until no new legitimate violations appear, then move the policy to Content-Security-Policy. For a quick look at the publicly visible settings there are free scanners; we put them in context in our small business website security checklist.
Common mistakes
1. unsafe-inline in script-src
The most common reason an existing CSP barely protects anything. Often 'unsafe-inline' was added because something broke after the policy went live. Replace it with hashes or nonces and move inline event handlers into script files. The same goes for 'unsafe-eval', which, unlike 'unsafe-inline', according to MDN still takes effect alongside hashes and nonces.
2. Sources that are too broad
Entries such as https:, * or the whole domain of a large provider with many customers allow far more than the one file you need. If arbitrary third-party content is hosted on an allowed domain, an attacker can load their script from there. List sources as narrowly as possible, and for script-src prefer hashes or nonces over domain lists.
3. CSP only as a meta tag
You can also set a CSP with <meta http-equiv="Content-Security-Policy">. Per the CSP specification, however, frame-ancestors, report-uri and sandbox do not work there, and report-only mode is not supported in a meta tag at all. A meta tag therefore does not protect against clickjacking. If you have access to the server or hosting configuration, send the CSP as an HTTP header.
4. Headers on the home page only
Headers set in the code of one page or in a single location block are missing from every other response. Typical gaps: subpages with their own rules, error pages without always, files served from another directory or a CDN, and responses from serverless functions on Netlify. The loop from step 1 finds this in a minute.
5. HSTS preload too early
If you set includeSubDomains; preload and submit the domain before every subdomain supports HTTPS, visitors are locked out of those subdomains, and removal takes months. Start without preload and extend once everything works.
6. Legacy headers as a substitute
X-XSS-Protection is no substitute for a CSP, and according to MDN current browsers ignore X-Frame-Options: ALLOW-FROM completely. To allow specific embedders, use frame-ancestors with those domains.
Checklist
| Area | Action |
|---|---|
| CSP | Sent as an HTTP header, with default-src, script-src without 'unsafe-inline', object-src 'none', base-uri, form-action, frame-ancestors |
| Inline scripts | Allowed by hash or nonce, no inline event handlers |
| Sources | As narrow as possible, no * and no https: in script-src |
| HSTS | max-age of at least one year, includeSubDomains only after checking every subdomain, preload only deliberately |
| Other headers | nosniff, Referrer-Policy set explicitly, Permissions-Policy for unused features |
| Legacy | X-XSS-Protection removed or 0, no ALLOW-FROM |
| Coverage | Headers measured with GET on subpages, static files and error pages |
| Rollout | New CSP in report-only mode first, console clean across all important features |
What headers cannot do
Security headers are a second line of defence. A CSP stops injected scripts from running; it does not stop someone from reaching data through a flaw in your form, your API or your login. Whether your application has such flaws only becomes clear when the application itself is tested. If personal data is actually accessed, that can trigger a notification duty under Article 33 GDPR. Whether it applies in a given case is a legal question. This article is not legal advice.
Are your headers actually doing their job?
We test your website within a scope agreed in writing: the security headers delivered on every type of page, your CSP against the scripts that really load, framing and redirects, plus forms, login and APIs. Every finding comes with reproducible evidence and a concrete fix for your hosting setup. Learn more about penetration testing.
Request a website security checkCyberSec42 is an independent technical security assessment, not an accredited certification body, and does not provide legal advice. CyberSec42 is not affiliated with Vercel, Netlify, Apache or nginx.
Frequently asked questions
Is a Content Security Policy in a meta tag enough?
Against injected scripts, a meta tag CSP works. But frame-ancestors, report-uri, sandbox and report-only mode do not work there. For clickjacking protection you need the HTTP header.
Do I still need X-Frame-Options?
Per the CSP specification, frame-ancestors overrides X-Frame-Options and is more flexible. Sending X-Frame-Options: DENY as well does no harm and covers older browsers.
Should I set X-XSS-Protection?
No. MDN marks the header as deprecated, and OWASP advises leaving it out or disabling it with 0. A CSP without 'unsafe-inline' in script-src does the job instead.
Why does curl -I show different headers from the browser?
curl -I sends a HEAD request, and RFC 9110 allows a server to omit headers that are only produced while generating the page. Caches serving older responses add to the confusion. Measure with GET using curl -s -D - -o /dev/null, with and without a cache buster.
Can a CSP break my website?
Yes, if it blocks scripts, fonts or connections your site needs. That is why you start with Content-Security-Policy-Report-Only, read the console and reports, and only then enforce.
Do good security headers replace a penetration test?
No. Headers blunt certain attacks in the browser. They do not find flaws in authentication, access control, forms or APIs. That takes a test of the application itself.
Further reading
- Website security check in 8 steps
- Small business website security checklist
- WordPress security: guide and checklist
- Shopify store security checklist for merchants
- Website hacked? What to do first
- Service: penetration testing
Sources
- W3C: Content Security Policy Level 3
- MDN: Content Security Policy (CSP)
- MDN: CSP frame-ancestors
- MDN: X-Frame-Options
- MDN: X-XSS-Protection
- MDN: Strict-Transport-Security
- MDN: X-Content-Type-Options
- MDN: Referrer-Policy
- MDN: Permissions-Policy
- W3C: Referrer Policy
- RFC 6797: HTTP Strict Transport Security (HSTS)
- RFC 9110: HTTP Semantics, section 9.3.2 HEAD
- HSTS Preload List Submission
- OWASP Secure Headers Project
- OWASP HTTP Headers Cheat Sheet
- Vercel Docs: vercel.json, headers
- Netlify Docs: Custom headers
- Apache HTTP Server: mod_headers
- nginx: ngx_http_headers_module
- EUR-Lex: General Data Protection Regulation (EU) 2016/679