The short version
For an ordinary website served over HTTPS, this is a reasonable set of response headers:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: (written for your site, see below)
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Four of these you can usually copy as they are. HSTS needs a ramp-up and a look at your subdomains first. CSP has to be written for your site; copying someone else’s policy is the most common way to break a working site with a security header.
None of these headers fix a vulnerability. They tell the browser to refuse things your site should never do anyway: load over plain HTTP, run a script from an unexpected place, render inside someone else’s frame. When a real bug exists, they limit what an attacker can do with it.
See what you send today
curl -s -D - -o /dev/null https://example.com/ \
| grep -iE '^(strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy)'
This makes a normal GET and prints only the response headers. curl -I sends a HEAD request instead, which most servers answer the same way, but some applications don’t, so GET is the safer test.
Check more than the home page:
- An error page (
https://example.com/does-not-exist). On nginx, headers added withoutalwaysdisappear from 4xx and 5xx responses. - Both hostnames,
example.comandwww.example.com. They are often served by different server blocks. - Whatever sits behind a different
location, such as/api/or/static/. More on why below.
The HTTP check does the same from outside: it follows redirects, reads the headers of the final response and grades them.
How DechoNet grades these headers
The grade covers six headers, 100 points in total:
| Header | Points | Counted as weak (half points) |
|---|---|---|
| Content-Security-Policy | 25 | doesn’t restrict scripts: no script-src/default-src (e.g. only frame-ancestors), a wildcard or https: source, 'unsafe-eval', or 'unsafe-inline' without a nonce, hash or 'strict-dynamic' |
| Strict-Transport-Security | 20 | max-age under 31536000 (one year) |
| X-Frame-Options | 15 | |
| X-Content-Type-Options | 15 | |
| Referrer-Policy | 15 | value is exactly origin |
| Permissions-Policy | 10 |
Then 3 points come off for each of Server, X-Powered-By, X-AspNet-Version and X-AspNetMvc-Version that is present, whatever its value. Grades: A+ from 95, A from 85, B from 70, C from 55, D from 40, F below.
COOP, CORP, COEP and X-XSS-Protection are shown in the result but carry no weight. Cross-origin isolation is something only certain apps need, and COEP can break ordinary pages. A value of 0 for X-XSS-Protection is labelled partial in the list; it doesn’t affect the grade, and 0 is in fact a correct value (see below).
Know what the grade can’t tell you. It checks that a header is present and catches a few obvious weaknesses. It does not judge whether your CSP restricts anything: Content-Security-Policy: frame-ancestors 'self' gets the full 25 points while doing nothing about scripts. It also only counts an enforced Content-Security-Policy response header, so a Report-Only policy or a <meta> policy shows as missing.
Content-Security-Policy
CSP tells the browser where each kind of resource may come from: scripts, styles, images, frames, form targets. Its main job is damage control for cross-site scripting. If an attacker manages to inject <script> into your page, a good policy stops the browser from running it.
It breaks sites because modern pages run code from many places: inline <script> blocks, onclick= attributes, analytics and tag managers that load more scripts, chat widgets, A/B testing tools, older libraries that use eval. A policy that doesn’t list all of them blocks them silently, and the only trace is in the browser console.
Roll it out in Report-Only first
The browser evaluates a Report-Only policy and reports what it would block, without blocking anything:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; report-uri /csp-report; report-to csp
Reporting-Endpoints: csp="https://example.com/csp-report"
report-uri is deprecated in favour of report-to plus the Reporting-Endpoints header, but browser support for the newer mechanism has been uneven, so most sites send both. The reports are JSON POSTs to your endpoint. Expect noise: browser extensions inject scripts into pages, and those show up as violations from chrome-extension:// or moz-extension:// sources. Filter those out and look at what’s left.
Run Report-Only long enough to cover every kind of page, including the ones nobody visits during testing: checkout, password reset, the admin area, the campaign landing page marketing set up last quarter. When the reports are down to extension noise, switch the header name to Content-Security-Policy. You can keep sending a stricter candidate as Report-Only next to the enforced one; browsers handle both headers at once.
A Report-Only policy can’t be set with a <meta> tag. An enforced policy can, but a meta policy ignores frame-ancestors, report-uri and sandbox. Use the response header.
Allowlists versus nonces
A policy built from host allowlists (script-src 'self' https://cdn.example.net https://www.google.com) is easy to write and weaker than it looks. Large CDNs and some big-platform domains serve scripts an attacker can reuse, such as old library versions or JSONP endpoints, so allowing the whole host can let an injected script through.
The stronger pattern is a nonce. The server generates a fresh random value for every response, puts it in the header, and adds it to each <script> tag it trusts:
Content-Security-Policy: script-src 'nonce-k8Fq2xR7pLm3' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="k8Fq2xR7pLm3" src="/app.js"></script>
With 'strict-dynamic', scripts loaded by a trusted script are trusted too, so a tag manager can still load its tags, and browsers that support it ignore host allowlists in script-src. When a nonce or hash is present, 'unsafe-inline' in script-src is ignored by CSP Level 2 and later browsers, which is why some policies include it as a fallback for very old ones.
The catch is caching. A nonce must be unpredictable and different on every response. If a CDN caches the HTML, every visitor gets the same nonce, and it protects nothing. For static or cached pages, use hashes of the inline scripts ('sha256-…') instead, which most build tools can generate.
Two directives worth adding to any policy: object-src 'none' (no plugins) and base-uri 'self' or 'none' (stops an injected <base> tag from redirecting your relative script URLs). If you’re cleaning up HTTP resources on an HTTPS site, upgrade-insecure-requests makes the browser fetch them over HTTPS; see mixed content.
On grading: DechoNet looks at the script sources only (script-src, or default-src when there is none). 'unsafe-inline' in style-src doesn’t cost points — inline styles are a much smaller risk than inline scripts — and 'unsafe-inline' next to a nonce or hash isn’t counted either, because browsers ignore it there. A policy that only sets frame-ancestors is worth having, but it does nothing against script injection, so it gets half points.
Strict-Transport-Security (HSTS)
HSTS tells the browser: for the next max-age seconds, only ever use HTTPS for this host, and don’t let the user click through a certificate error. It closes the window where someone types example.com, the browser tries plain HTTP first, and an attacker on the network intercepts that first request.
Things to know before you set it:
- Browsers ignore the header when it arrives over plain HTTP. It only counts on HTTPS responses.
- The very first visit is unprotected, because the browser hasn’t seen the header yet. Preloading fixes that; see the HSTS preload checklist.
- You can’t take it back quickly. A browser that has seen
max-age=31536000will insist on HTTPS for a year. To retract it you sendmax-age=0and wait for each visitor to come back and receive it.
So ramp up: start with max-age=300, then a day, a week, a month, and only then a year, checking at each step that nothing served over HTTP is still needed.
includeSubDomains applies the rule to every subdomain. That’s the right end state and the reason it bites: an intranet host, a printer interface or an old HTTP-only hostname under your domain becomes unreachable in browsers that saw the header. Make a list first. Subdomain discovery shows names that appear in certificate transparency logs; internal names that never had a public certificate won’t be there, so ask whoever runs internal DNS.
DechoNet counts max-age below one year as weak (10 of 20 points). It reads the header from the final page after redirects. For preloading, the header must also be on the apex domain’s HTTPS response, even when that response is only a redirect to www, so check that one directly:
curl -sI https://example.com/ | grep -i strict-transport
X-Frame-Options and frame-ancestors
Both stop other sites from loading your pages in a frame, which is how clickjacking works: your real page sits invisibly under a decoy, and the user’s clicks land on your buttons.
X-Frame-Options has two useful values, DENY and SAMEORIGIN. The third, ALLOW-FROM, isn’t supported by current browsers; a header using it is treated as if it weren’t there.
CSP’s frame-ancestors is the modern replacement and can list specific sites:
Content-Security-Policy: frame-ancestors 'self' https://partner.example.org
When both are present, browsers that support frame-ancestors use it and ignore X-Frame-Options. So if a partner legitimately embeds your page (a payment widget, a learning platform), put the partner in frame-ancestors and keep X-Frame-Options: SAMEORIGIN for older clients. Modern browsers will allow the partner; very old ones will block it, which is usually acceptable. frame-ancestors only works as a response header, not in a meta tag.
X-Content-Type-Options
There is one valid value: nosniff. It stops the browser from guessing a file’s type from its contents, and makes it refuse a script or stylesheet whose Content-Type is wrong.
That second part is the only way it breaks things. If your server sends JavaScript as text/plain or text/html, those scripts stop running once you add the header. Check before you deploy:
curl -sI https://example.com/assets/app.js | grep -i content-type
You want text/javascript or application/javascript for scripts and text/css for stylesheets.
Referrer-Policy
This controls how much of the current URL the browser sends in the Referer header when someone follows a link or the page loads a resource from elsewhere.
Current browsers already default to strict-origin-when-cross-origin, or something stricter, when no policy is set: the full URL to your own site, only the origin to other sites, nothing when going from HTTPS to HTTP. Sending it explicitly fixes that behaviour regardless of browser defaults. If your URLs carry anything sensitive, such as reset tokens, internal search terms or patient identifiers, use same-origin or no-referrer instead, accepting that partner sites will no longer see where their traffic came from.
Avoid unsafe-url and no-referrer-when-downgrade, which send full URLs, query strings included, to other sites. DechoNet counts origin as weak because it still sends the origin when going from HTTPS to HTTP.
Permissions-Policy
Turns off browser features your site doesn’t use, for your pages and for any frames inside them:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
() means nobody, (self) means only your own origin, and (self "https://maps.example.com") adds a specific embedded site. It replaced the older Feature-Policy header, which used a different syntax (camera 'none'). Header support is strongest in Chromium-based browsers. Chrome logs a console warning for feature names it doesn’t recognise, such as a leftover interest-cohort=(); that’s harmless, but you can delete it.
Don’t switch off something an embed needs. A payment provider’s iframe may need payment, a video-call widget needs camera and microphone. Those frames also need the matching allow= attribute on the <iframe> tag.
X-XSS-Protection: leave it out
This header controlled a built-in XSS filter in older browsers. No current major browser still has that filter. It was retired partly because it caused problems of its own: attackers could trigger it to switch off legitimate scripts on a page or to leak information across sites.
Don’t send X-XSS-Protection: 1; mode=block on a new site. Either omit the header or send X-XSS-Protection: 0, which turns the filter off in any old browser that still has one. Your protection against XSS is output encoding plus CSP.
Configuring the headers
nginx
server {
listen 443 ssl;
server_name example.com www.example.com;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
server_tokens off;
}
Two traps catch nearly everyone.
always. Without it, nginx adds the header only to successful and redirect responses. Your 404 and 500 pages go out without any of these headers.
Inheritance. A location block that contains any add_header of its own inherits none of the add_header lines from the server block. Add a Cache-Control header to location /static/ and that whole path silently loses every security header. The usual fix is to put the security headers in a snippet file and include it in the server block and in every location that has its own add_header. To see what nginx is actually running with:
sudo nginx -T 2>/dev/null | grep -nE 'server_name|location|add_header|include'
If nginx proxies to an application that sets the same header, you send it twice. Two CSP headers are both enforced, so the effective policy is at least as strict as each of them. Two copies of other headers are just ambiguous. Pick one place to set each header, and use proxy_hide_header Content-Security-Policy; to drop the application’s copy if nginx should own it.
Apache
With mod_headers enabled (sudo a2enmod headers on Debian and Ubuntu):
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>
Put HSTS in the :443 virtual host. These lines also work in .htaccess if AllowOverride permits it. Stick to Header always set for these headers: Apache keeps separate lists for always and normal headers, and setting the same header through both is a common source of duplicates. Run apachectl configtest before reloading.
Cloudflare
- HSTS: SSL/TLS → Edge Certificates → HTTP Strict Transport Security. Cloudflare then adds the header at its edge.
- Other headers: a response header Transform Rule (under Rules) that sets each header to a static value, for all requests or for a hostname. Cloudflare also offers a managed transform that adds a fixed bundle of basic security headers; check exactly what it sends before relying on it.
- Cloudflare Pages: a
_headersfile in the build output:
/*
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
If the origin sets a header and Cloudflare sets it too, check with curl which value visitors actually get.
The information-leak headers
DechoNet takes 3 points off for each Server, X-Powered-By, X-AspNet-Version or X-AspNetMvc-Version header present. The real risk is in version numbers, which let an attacker match your server against known vulnerabilities without probing. A bare product name tells them little.
Removing the versions is easy:
- nginx:
server_tokens off;(leavesServer: nginx) - Apache:
ServerTokens ProdandServerSignature Off(leavesServer: Apache) - PHP:
expose_php = Offin php.ini removesX-Powered-By - Express:
app.disable('x-powered-by') - ASP.NET:
<httpRuntime enableVersionHeader="false" />removesX-AspNet-Version
Removing the Server header entirely isn’t possible in stock nginx or Apache; it takes an extra module, such as headers-more for nginx. Behind a CDN, the header usually names the CDN, which tells an attacker nothing they can’t already see from your DNS. Hiding versions is worth doing. Chasing the last 3 points is optional.
After you change anything
- Re-run the curl command against the home page, an error page, both hostnames and any path with its own configuration.
- Open the site with the browser console visible and click through the main flows. CSP and
nosniffproblems show up there first. - Run the HTTP check to see the result from outside, after redirects.
- After switching CSP from Report-Only to enforced, keep the reporting endpoint and watch it for at least a week.
Header changes are also a good moment to look at caching, because the same add_header inheritance rule applies there; see the speed, cache and compression checklist.
Check your own domain now
Free, no sign-up. Runs the exact check this guide describes and shows what to fix.