Read this before you paste anything
Two things bite almost everyone who does this on their own server. One is nginx specific and it is genuinely nasty. The other is an Apache keyword that people leave out.
On nginx, add_header does not merge. Directives are inherited from the previous level only if there are no add_header directives at the current level. Read that again. If your server block sets six security headers and one location block sets a single add_header Cache-Control ..., that location silently drops all six. No error, no warning, no log line. Your homepage passes a scan and your product pages ship naked.
On Apache, use Header always set, not Header set. Without always, the header is attached only to successful responses. Error pages and internal redirects go out without it. A 404 page with no X-Content-Type-Options is still a 404 page an attacker can work with.
The Apache version
This goes in your vhost, or in .htaccess if you cannot reach the vhost. Pick one layer only, never both.
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Content-Security-Policy "object-src 'none'; frame-ancestors 'self'; base-uri 'self'"
Header always set Strict-Transport-Security "max-age=300"Start HSTS at max-age=300 while you confirm HTTPS works everywhere, then ramp it up. More on that below.
Making the Apache version actually apply
Three requirements, and skipping any of them means your headers are simply not there.
- →mod_headers must be enabled. On Debian and Ubuntu that is a2enmod headers followed by a restart. If the module is missing, the Header directive is an unknown directive and Apache refuses to start.
- →.htaccess needs AllowOverride FileInfo. If the vhost does not grant it, your .htaccess Header lines are ignored or throw a 500. Setting the headers in the vhost avoids this entirely and is faster, because Apache stops walking directories looking for .htaccess files.
- →Wrapping in <IfModule mod_headers.c> is a trade-off. It stops the config exploding when the module is absent. It also means a missing module produces a site with zero security headers and no complaint at all. Safer to boot, worse to diagnose. If you are the one who runs the box, we would rather it fail loudly.
- →Do not set the same header in two places. Vhost plus .htaccess, or a server config plus an application framework, gives you duplicates. For CSP specifically, browsers apply the intersection of every policy present, so two reasonable policies combine into one that blocks things neither author intended.
The nginx version
Same headers, nginx syntax. Note the always parameter on every line, which is what extends the header to 4xx and 5xx responses.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'self'; base-uri 'self'" always;
add_header Strict-Transport-Security "max-age=300" always;Put this in your server block. Then read the next section before you consider it done.
The nginx inheritance trap, and how to beat it
You cannot fix this with discipline. Someone will add a caching header to a location block in six months and undo your work without knowing. So build it so that cannot happen.
Save the five lines above as a snippet file, for example /etc/nginx/snippets/security-headers.conf. Include it in the server block. Then include it again in every single block that sets any add_header of its own.
server {
include snippets/security-headers.conf;
location /assets/ {
add_header Cache-Control "public, max-age=31536000" always;
include snippets/security-headers.conf; # without this line, the five headers above vanish here
}
}The comment is the whole point. Without that include, /assets/ inherits nothing, because the location block sets an add_header of its own.
Sane values, header by header
What we would actually set, and why.
Stops browsers guessing file types. No downside, no configuration, nothing to break. If you set one header today, set this one.
Already the default in modern browsers, so setting it is close to zero risk. You are making the default explicit and covering older clients.
Anti-clickjacking. Use DENY if nothing ever frames you. ALLOW-FROM is obsolete and modern browsers ignore it, so do not reach for it. Use CSP frame-ancestors instead.
The powerful one, and the one that breaks sites. Never paste a strict policy straight into production. Use the ladder below.
Forces HTTPS on return visits. Only enable it once HTTPS genuinely works on every hostname you serve. Start low, ramp up.
Restricts camera, microphone and similar APIs. Nice to have, but it is not what a scanner or a shopper is looking at. Fix the five above first.
The CSP ladder, so you do not break your own site
A strict CSP really does break things. Inline scripts, analytics tags, embedded video, payment widgets, that one legacy jQuery snippet in the footer. Anyone who tells you to paste a tight policy and see what happens has not done it on a site that takes money.
Rung one: report only. Ship your intended policy as Content-Security-Policy-Report-Only. It enforces nothing. The browser reports what it would have blocked, and you get the real inventory of what your site loads instead of the one you imagined.
Rung two: permissive but real. The policy in the snippets above is deliberately mild. It blocks Flash-era plugin content, stops other sites framing you, and stops injected markup rewriting your base URL. It breaks almost nothing on a normal site, and it is a genuine improvement over having no CSP at all.
Rung three: tighten. Now start constraining script-src and default-src, using what your report-only run told you. This is where the real protection lives, and it is also where the outages live. Go slowly.
If you want the concepts rather than the config, our CSP glossary entry is the short version.
HSTS, and why we are cautious about preload
HSTS tells browsers to refuse plain HTTP for your domain. That is good, and it is also a commitment you cannot take back quickly.
Order matters. Working certificate first. Then force HTTPS. Then HSTS. Never the other way around.
Start at max-age=300. If something is broken you find out in five minutes rather than a year. Once you are confident, ramp to a longer max-age.
Preload deserves real hesitation. Getting on the hstspreload.org list requires max-age of at least 31536000, plus includeSubDomains, plus preload, plus a valid certificate, plus an HTTP to HTTPS redirect on the same host, and every single subdomain reachable over HTTPS. That last one is what catches people: a forgotten internal box on a subdomain becomes unreachable for your users. Their own wording is that inclusion "cannot easily be undone" and removal "takes months to reach users with a Chrome update". Do not add preload because a checklist told you to. Add it because you have decided your domain is HTTPS forever. The HSTS glossary entry has more.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"The mature setting for most sites: a full year, subdomains covered, no preload. Note the deliberate absence of the preload token.
Confirm it worked
Check a page, then check a static asset, then check a URL that 404s. Those three cover the Apache always issue and the nginx inheritance issue between them. If all three carry your headers, you are done.
Then run the whole site through our free safety check to see it from the outside. While you are in server config, our security.txt guide is the next ten minute win.
Run your site through our free safety check to confirm the fix is live, and see what else a shopper would notice.
Run a free checkFrequently asked questions
Why do my nginx security headers disappear on some pages?
Almost certainly the inheritance rule. nginx inherits add_header directives from the previous level only if there are no add_header directives at the current level. One add_header inside a location block discards every header inherited from the server block. Repeat the headers in that block, or better, put them in an include snippet and include it in every block that sets any header.
What is the difference between Header set and Header always set in Apache?
Header set only attaches the header to successful responses. Header always set also covers error pages and internal redirects. Use always. There is no real reason not to for security headers, and leaving it out means your 404 and 500 pages go out unprotected.
Should I put security headers in .htaccess or the vhost?
The vhost if you can reach it. It is faster, it is version controlled, and it does not depend on AllowOverride FileInfo being granted. Use .htaccess when you have no other option. Either way, pick one layer. Setting the same header in two places duplicates it, and duplicate CSP headers combine into the intersection of both policies, which blocks more than you intended.
Will a Content-Security-Policy break my site?
A strict one, yes, quite possibly. That is not scaremongering, it is the normal experience. Ship it as Content-Security-Policy-Report-Only first and read what the browser says it would have blocked. Then move to a mild but genuine policy like object-src none, frame-ancestors self, base-uri self, which breaks almost nothing. Tighten script-src from there.
Should I submit my domain to the HSTS preload list?
Only if you are certain. It needs max-age of at least a year, includeSubDomains, the preload token, a valid cert, an HTTP to HTTPS redirect, and every subdomain on HTTPS. Inclusion cannot easily be undone, and removal takes months to reach users through a Chrome update. A one year max-age with includeSubDomains and no preload gives you most of the benefit and keeps the exit door open.
Is X-Frame-Options ALLOW-FROM still usable?
No. It is obsolete and modern browsers ignore it, so a site relying on it has no framing protection at all. Use the CSP frame-ancestors directive instead, which does support a list of permitted origins and is actually honoured.