Security headers are short instructions your server sends with every page. They tell the browser to always use HTTPS, refuse to be framed by other sites, and only run scripts from places you trust. They cost nothing in speed and close off whole classes of attacks such as clickjacking and some cross-site scripting. This guide adds a solid set to Nginx in one reusable snippet.
Before you start
- A Linux VPS with Nginx serving your site over HTTPS. Several of these headers only make sense on a secure site. See Redirect HTTP to HTTPS in Nginx.
- SSH access with sudo.
- A list of outside services your pages load from, such as Google Fonts, analytics or a CDN. You need it for the Content Security Policy.
The headers and what they do
- Strict-Transport-Security (HSTS): tells browsers to use HTTPS for your domain for a set time, even if someone types
http://. - X-Content-Type-Options: nosniff: stops browsers guessing a file's type, so an uploaded text file cannot be run as a script.
- X-Frame-Options: controls whether other sites can show your pages in a frame. Prevents clickjacking.
- Referrer-Policy: limits how much of your URL is shared with other sites when visitors click links.
- Permissions-Policy: turns off browser features you do not use, such as the camera or geolocation.
- Content-Security-Policy (CSP): lists the sources the browser may load scripts, styles, images and frames from. The most powerful header, and the one most likely to break things if rushed.
Create a reusable snippet
Put the headers in one file so every site can include it:
sudo mkdir -p /etc/nginx/snippets
sudo nano /etc/nginx/snippets/security-headers.confadd_header Strict-Transport-Security "max-age=300" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'" always;Why the always flag matters
Without always, Nginx only adds headers to successful responses (200, 301, 302, 304 and a few others). Error pages such as 404 and 500 would go out without them. always adds them to every response.
Include it in your site
In the HTTPS server block of each site:
server {
listen 443 ssl;
server_name example.com;
include snippets/security-headers.conf;
}Keep your existing directives in the block; only add the include line.
Warning: add_header inheritance is all or nothing. If a location block has even one add_header of its own (for example a Cache-Control header), it drops every header from the server block. Add include snippets/security-headers.conf; inside those locations too.
Roll out HSTS safely
The snippet starts HSTS at max-age=300, five minutes. Browsers enforce HSTS for the full time and visitors cannot click past a certificate error while it is active. Once you have confirmed HTTPS works on every page and renewals succeed, raise it in steps:
add_header Strict-Transport-Security "max-age=31536000" always;Add includeSubDomains only when every subdomain, including ones on other servers, has a valid certificate. The preload option asks browsers to hard-code your domain; it is very hard to undo, so only use it once you are certain.
Build a Content Security Policy
The snippet sends CSP as Report-Only. Browsers do not block anything yet; they print what would have been blocked in the developer console. That lets you tune the policy without breaking your site.
- Reload Nginx, open your site, press F12 and look at the Console.
- Each warning names a blocked source, such as
https://fonts.googleapis.comfor styles. - Add trusted sources to the matching directive, for example
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com. - Browse every type of page: forms, checkout, admin, embedded videos.
- When the console is clean, rename the header from
Content-Security-Policy-Report-OnlytoContent-Security-Policy.
WordPress and many page builders rely on inline scripts. You may need 'unsafe-inline' in script-src at first. It weakens the policy, but the other directives still help.
frame-ancestors 'self' is the modern version of X-Frame-Options. Keeping both supports older browsers.
Apply the change
sudo nginx -t && sudo systemctl reload nginxCheck it works
curl -sI https://example.com/ | grep -iE "strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy|content-security"All six headers should appear. Check a missing page too, to confirm always works:
curl -sI https://example.com/no-such-page | grep -i x-frameFor a graded report, run your domain through an online security header scanner such as Mozilla's HTTP Observatory or securityheaders.com.
Troubleshooting
- Headers appear on some pages but not others: a
locationhas its ownadd_header, which drops the inherited ones. Include the snippet in that location. - Headers appear twice: your app (WordPress plugin, framework middleware) also sets them. Keep them in one place.
- Site layout breaks after switching CSP to enforce: a source is missing. Switch back to Report-Only, read the console and add it.
- Embedded pages on your other domain refuse to load:
X-Frame-Options SAMEORIGINandframe-ancestors 'self'block them. Add the other domain toframe-ancestorsand removeX-Frame-Optionsfor that site. - Visitors cannot reach the site after a certificate problem: HSTS forces HTTPS. Fix the certificate; you cannot switch HSTS off for browsers that already saw it.
Next steps
- Harden the rest of the server config: Hide the Nginx version and harden the default config.
- Use modern TLS settings: Modern TLS configuration for Nginx.
