Setting security headers correctly in nginx and Apache

A few lines in the server configuration protect against clickjacking, MIME sniffing and many consequences of XSS. The key is to set them so they really arrive with every response.

Which headers make sense

X-XSS-Protection is no longer needed. Modern browsers ignore it, and in old ones it could even do harm.

nginx

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" 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;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;

# in the server block:
include snippets/security-headers.conf;
server_tokens off;

nginx has two traps:

Apache

# sudo a2enmod headers
<VirtualHost *:443>
    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
    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=()"
    Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
    Header always unset X-Powered-By
</VirtualHost>

# global, e.g. in security.conf
ServerTokens Prod
ServerSignature Off

On shared hosting without access to the server configuration, the same Header lines usually work in the .htaccess in the document root. Turn off the PHP version in the X-Powered-By header with expose_php = Off in php.ini.

Rolling out a CSP without breaking the site

A CSP that is too strict blocks your own scripts, embedded videos or analytics. So proceed in stages:

  1. First set it as Content-Security-Policy-Report-Only. The browser blocks nothing but reports violations in the console.
  2. Go through the console on the most important pages and add the sources you need, e.g. script-src 'self' https://cdn.example.net.
  3. Move inline scripts into files or give them a nonce. 'unsafe-inline' in script-src largely defeats the XSS protection.
  4. Only then switch to Content-Security-Policy.

Be careful with HSTS

HSTS cannot simply be taken back: browsers remember it for the given time. Start with max-age=300, check that really all subdomains support HTTPS, then raise it to two years. includeSubDomains also covers internal names such as intranet.example.com if they are under the same domain.

Checking

curl -sI https://example.com
curl -sI https://example.com/does-not-exist   # on 404 too?

The security header check rates all headers, spots typical mistakes such as 'unsafe-inline' and generates the matching lines for your server.

FAQ

Is X-Frame-Options enough or do I need frame-ancestors?

frame-ancestors in the CSP is the modern way and also allows several sources. For very old browsers, adding X-Frame-Options: SAMEORIGIN does no harm.

Why do I see the headers twice?

Usually both the application (e.g. PHP or a CMS) and the web server set them. Duplicate CSP headers are both enforced, so the stricter one wins. Best set them in one place only.

Do APIs need security headers too?

Yes, at least HSTS, nosniff and a tight CSP such as default-src 'none'; frame-ancestors 'none'. They cost nothing and prevent responses from being abused in the browser.

Does a CDN set the headers for me?

Only if you configure it there. Many CDNs simply pass through the origin server’s headers.