/ Security Audit Fixes / nginx

How to Fix Security Headers in Bare nginx

Bare nginx — without Plesk, cPanel or any control panel — gives you full control over every header, every cipher, every protocol. This guide walks through every fix our Security Audit raises on a directly-configured nginx server: adding the six recommended HTTP security headers, configuring modern TLS protocols and publishing security.txt. Tested against nginx 1.24 on Ubuntu 22.04 / 24.04 LTS. For Apache, see the Apache variant; for the full finding catalogue, see Security Audit Fixes.

1. Add the six HTTP security headers

The six headers our audit checks (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) all go inside your server block via add_header. The critical nginx quirk: add_header at parent scope is silently replaced when any add_header appears in an inner block, so put all six in the same location to avoid surprises.

Step 1
Locate your vhost config
The exact path depends on your distribution. Common locations:
/etc/nginx/sites-available/yourdomain.conf      # Debian/Ubuntu convention
/etc/nginx/conf.d/yourdomain.conf               # CentOS/RHEL/Amazon Linux
/etc/nginx/nginx.conf                            # Single-file deployments
Open the file with your preferred editor:
sudo nano /etc/nginx/sites-available/yourdomain.conf
Step 2
Add the six directives to your server block
Inside the server { ... } block that handles HTTPS (port 443), paste the following six directives. Always include always so the headers apply to error responses too:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 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 "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'self'" always;
⚠️ The Content-Security-Policy is a sensible default but may block legitimate third-party scripts (analytics, embedded video, advertising). Test in a staging environment first. Start with Google's CSP Evaluator if you need to loosen it.
Step 3
Test and reload
Always test config before reloading:
sudo nginx -t
If the test passes, reload:
sudo nginx -s reload
Or via systemd:
sudo systemctl reload nginx
Step 4
Verify the headers are present
Test with curl from outside the server:
curl -sI https://yourdomain.com/ | grep -iE "strict-transport|x-frame|x-content|referrer|permissions|content-security"
All six headers should appear in the output. If any are missing, check that the add_header directives are inside the right server block and that you reloaded (not just edited) nginx.
💡 Reusing security headers across many domains? Put them in a single file like /etc/nginx/snippets/security-headers.conf and include snippets/security-headers.conf; inside each server block. One source of truth, easy to update.

2. Configure modern TLS protocols

The audit also flags TLS 1.0 and TLS 1.1 — both deprecated, both insecure. nginx defaults vary by distribution, so do not assume your server is already on TLS 1.2+.

Step 1
Set protocols and ciphers
In the same server block (or globally in nginx.conf http block):
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_prefer_server_ciphers off is correct for modern TLS — clients now pick better ciphers than servers do.
Step 2
Verify with SSL Labs
Run SSL Labs SSL Test against your domain. Target grade: A or A+. A+ requires HSTS preload, which you already added in step 1.

3. Publish security.txt at /.well-known/security.txt

RFC 9116 standardises the /.well-known/security.txt file so security researchers know where to report vulnerabilities. Browsers and audit tools both check for it.

Step 1
Create the file in your document root
Make the directory if it does not exist:
sudo mkdir -p /var/www/yourdomain/public/.well-known
Create security.txt with at minimum Contact and Expires:
Contact: mailto:security@yourdomain.com
Expires: 2027-05-18T00:00:00.000Z
Preferred-Languages: en
Canonical: https://yourdomain.com/.well-known/security.txt
Adjust the email and expiry date to suit. Expiry must be a future date — past expiry invalidates the file.
Step 2
Add a location block for correct Content-Type
nginx may serve .txt with the wrong MIME type by default. Force text/plain:
location = /.well-known/security.txt {
    default_type text/plain;
}
The = makes it an exact match so it does not interfere with other .well-known paths (such as ACME challenges from Let's Encrypt).
Step 3
Reload and verify
sudo nginx -t && sudo nginx -s reload
curl -sI https://yourdomain.com/.well-known/security.txt
Expected: HTTP/2 200 and content-type: text/plain.

4. Re-run the audit

After all three sections above are complete, run the Security Audit against your domain. All six headers should pass, TLS protocols should be modern-only, and the security.txt check should show green. Most bare-nginx servers reach 95-100/100 after this guide.

💡 If you manage 10+ sites on bare nginx, build the security headers into a shared snippet and source it everywhere. A single change rolls out site-wide via nginx -s reload.

🛡 Run the audit after fixing

Verify every header, TLS protocol and security.txt with a fresh scan.

Run Security Audit →
Related Guides: Security Audit Fixes  ·  Fix in Apache  ·  Fix via Cloudflare  ·  Security Audit Guide
💬 Got a problem?

About aiwebpageseo

aiwebpageseo.com is a data-driven SEO and AEO (Answer Engine Optimisation) platform providing a free suite of technical SEO tools. Rather than relying on AI-theorised assumptions, the platform analyses live URL performance, delivering objective diagnostics, page speed metrics, CLS debugging, and site crawl data alongside actionable technical tutorials.

Frequently asked questions

Where should I put add_header directives in nginx?
Inside the server block of your vhost config. Putting them at http-block level applies to all domains but can be overridden silently by inner blocks. For per-domain headers, put them inside the relevant server block and use the 'always' parameter so they apply to error responses too.
Why does my CSP break when I have add_header in both server and location blocks?
nginx replaces parent add_header directives entirely when any add_header appears in an inner block — it does not merge them. If you have add_header in a location block, all add_header from the server block stop applying to that location. Repeat them in the location, or use a single include file.
Do I need 'always' on add_header in nginx?
Yes for security headers. Without 'always', headers only apply to 2xx, 204, 301, 302, 303, 307, 308, 403 and 404 responses. They are stripped from 500 errors and other status codes — which is exactly when an attacker is probing for weaknesses. Use add_header ... always; for all security headers.
How do I serve /.well-known/security.txt as text/plain in nginx?
Add a location block: location = /.well-known/security.txt { default_type text/plain; } — the = makes it an exact match so it does not interfere with other .well-known paths. Without this, nginx may serve it with the wrong Content-Type from your mime.types file.