Nginx security headers, HTTPS redirect and HSTS (and the add_header trap)
Published 7 October 20267 min readBy the SmoothSeen editorial team
In nginx, the HTTPS redirect is a port 80 server block with return 301 to the canonical https URL. HSTS and the other security headers are added with add_header and the always parameter in the port 443 server block. One catch: an add_header inside a location cancels the inherited ones. Since nginx 1.29.3, add_header_inherit merge prevents that.
Key points
- A port 80 server block with return 301 to the canonical URL takes http, https and the bare domain to the final address in a single hop.
- Without the always parameter, add_header only acts on some status codes; with it, HSTS and the other headers also appear on 404 errors.
- An add_header inside a location wipes out every header inherited from the server block. We confirmed it on nginx 1.30.5 and 1.31.6.
- Since nginx 1.29.3, add_header_inherit merge adds the parent level's headers; on older versions, repeat them with an include.
- Mozilla's TLS configuration generator has moved to TLSRef Configurator, whose intermediate profile supports TLS 1.2 and 1.3.
To check it on your own site: SEO audit
On this page
Nginx controls three things here: where each request is redirected, which headers travel with each response and which TLS versions it accepts. It does not control what a CDN in front of it does, nor the headers your application already sends when nginx acts as a proxy. Every snippet was tested on 7 October 2026 with nginx 1.30.5 (stable) and nginx 1.31.6 (mainline), the current versions according to nginx.org1, in their official Docker images (nginx:stable and nginx:mainline), with a self-signed certificate and curl from a second container. Compression, caching and AI bots are in the guide to nginx gzip, Brotli, caching and bots.
Step 1: redirect http to https with return 301
The version found in almost every tutorial uses the $host variable:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}$host is the host name of the request and $request_uri the full original URI, arguments included2. It works, but our test showed two drawbacks:
- Two hops.
http://example.com/goes tohttps://example.com/and then, if your canonical hostname has www, to a second redirect. - It echoes whatever host it is sent. If that is the only server block on port 80, it also answers requests for any other name. With the header
Host: otro-dominio.test, nginx repliedLocation: https://otro-dominio.test/blog/.
The canonical version spells out the hostname and adds a port 443 server block for the bare domain:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://www.example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/nginx/certs/example.crt;
ssl_certificate_key /etc/nginx/certs/example.key;
add_header Strict-Transport-Security "max-age=300" always;
return 301 https://www.example.com$request_uri;
}Result on both nginx versions: http://example.com/blog/?a=1 reached https://www.example.com/blog/?a=1 with a single 301, query string intact. If you prefer the bare domain, swap the names.
Step 2: HSTS with always
HSTS (Strict-Transport-Security) is a header that tells the browser to use HTTPS for that hostname for max-age seconds. Browsers ignore it when it arrives over an unencrypted connection3, so it belongs in the port 443 server blocks, never in the port 80 one.
The always parameter is not optional. The nginx documentation says add_header only adds the header when the response code is 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308, unless always is set4. Without it, a 404 or a 500 goes out with no HSTS and no other security header.
Raise max-age in stages, as the preload service recommends: five minutes (300), one week (604800) and one month (2592000), waiting out the full max-age at each stage; then a year (31536000)5. Before adding includeSubDomains, make sure every subdomain has HTTPS, because the policy applies to all of them3. And preload needs a one-year max-age, includeSubDomains and the header on redirects as well; getting off that list takes months5.
Step 3: the other headers and server_tokens
This is the canonical hostname's server block, exactly as tested:
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.example.com;
root /var/www/example;
ssl_certificate /etc/nginx/certs/example.crt;
ssl_certificate_key /etc/nginx/certs/example.key;
server_tokens off;
add_header Strict-Transport-Security "max-age=300" always;
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 Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; frame-ancestors 'self'; report-uri /csp-reports" always;
}- Header
X-Content-Type-Options: nosniff- What it does
- Stops the browser guessing a file type other than the declared one
- Header
Referrer-Policy- What it does
- Only the origin, not the path, reaches other sites
- Header
X-Frame-Options: SAMEORIGIN- What it does
- Stops other sites framing yours; CSP
frame-ancestorssupersedes it6
- Header
Permissions-Policy- What it does
- Turns off camera, microphone or geolocation if you do not use them
- Header
Content-Security-Policy-Report-Only- What it does
- Trials a CSP without blocking anything; you need
report-toorreport-urito receive reports7
server_tokens off removes the nginx version from error pages and from the Server header2. In our test, the server block with the directive answered Server: nginx, while the redirect blocks without it answered Server: nginx/1.30.5. Put it in the http block so it covers every server block (we tested that too). Do not add X-XSS-Protection: 1: MDN warns it can create vulnerabilities and recommends CSP instead8.
The add_header trap: a location wipes out your headers
The nginx documentation says it in one sentence: add_header directives are inherited from the previous level if and only if there are no add_header directives on the current level4. A single add_header Cache-Control in a location is enough for that location to lose HSTS and everything else.
We tested it with three location blocks inside the server block above:
# The trap: this add_header wipes out the server's six headers on /blog/
location /blog/ {
add_header Cache-Control "no-cache";
}
# Fix 1 (nginx 1.29.3 or later): add the parent level's headers
location /assets/ {
add_header_inherit merge;
add_header Cache-Control "max-age=604800";
}
# Fix 2 (any version): repeat them with an include
location /docs/ {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache";
}- URL requested
/- Security headers received
- All 6
- Cache-Control
- No
- URL requested
/blog/- Security headers received
- None
- Cache-Control
no-cache
- URL requested
/assets/styles.css- Security headers received
- All 6
- Cache-Control
max-age=604800
- URL requested
/docs/- Security headers received
- All 6
- Cache-Control
no-cache
Results were identical on 1.30.5 and 1.31.6. The add_header_inherit directive exists since nginx 1.29.3, accepts on (the long-standing behaviour), off and merge, and can go in http, server or location4. If your distribution ships an older version, use the include with a file holding the server block's six add_header lines.
Which TLS versions and which ciphers?
Since nginx 1.23.4, the default for ssl_protocols is TLSv1.2 TLSv1.39. If your nginx is recent and you leave ssl_protocols alone, you are already on those two versions; if you inherited a configuration that mentions TLSv1 or TLSv1.1, remove them.
For ciphers and the remaining parameters, do not copy lists from an old blog post. The Mozilla SSL Configuration Generator now says on its page that it has moved to TLSRef Configurator10. The TLSRef guidelines (version 6.0) recommend the intermediate configuration, with TLS 1.2 and 1.3, for a general-purpose server, and have dropped the old "Old" profile11. Generate the block for your nginx and OpenSSL versions, then test it.
How to check it
- Redirect.
curl -IL http://example.com/should end at your canonical https URL after a single301. - Headers on every path.
curl -I https://www.example.com/and, above all, one URL from eachlocationwith its ownadd_header. If any is missing the headers, it is the inheritance trap. - Errors. Request a URL that does not exist: the 404 should carry the headers.
- Syntax.
nginx -tbefore every reload. - Public grade. Mozilla's HTTP Observatory analyses the redirect, HSTS and the other headers12.
What SmoothSeen does with this
Within its SEO analysis, SmoothSeen checks whether the page answers over HTTPS, whether the http version redirects to https and which security headers it sends, using Mozilla's HTTP Observatory. It looks at the URL you analyse, as a browser would: if one section of your site loses its headers through add_header inheritance, you will see it when you analyse a page from that section.
What to do this week
List the location blocks in your configuration that use add_header and request one URL from each with curl -I. If any is missing headers, add add_header_inherit merge; or an include, and start HSTS at max-age=300. To review this alongside the rest of your technical SEO, run an SEO audit.
Frequently asked questions
Why does nginx not send my headers on some pages?
Almost always because of add_header inheritance: if the location serving that page has its own add_header, for caching for instance, it stops inheriting all of the server block's. The other usual cause is forgetting always, which leaves the headers off 404 and 500 responses. Check both with curl -I.
Should I use return 301 or rewrite for the redirect?
return 301 with the full URL is the most direct way to send a whole hostname elsewhere: it evaluates no regular expressions and makes the destination obvious. rewrite makes sense when the target depends on parts of the original path. For http to https, return is enough.
Can I put add_header_inherit merge in the http block?
Yes. The nginx documentation allows the directive in http, server and location, so you can set it once at the top if you want every level to add its headers to the inherited ones. It only exists since nginx 1.29.3, so older versions will reject it at nginx -t: check your version with nginx -v first.
Do I need X-Frame-Options if my CSP has frame-ancestors?
MDN says the CSP frame-ancestors directive supersedes X-Frame-Options. While your CSP is still in Report-Only mode, though, frame-ancestors only reports and does not protect, so keep X-Frame-Options: SAMEORIGIN until the CSP is actually enforced.
Sources
- 1nginx: download, nginx.org, accessed 7 October 2026.
- 2Module ngx_http_core_module, nginx.org, accessed 7 October 2026.
- 3Strict-Transport-Security, MDN Web Docs, updated 11 September 2026.
- 4Module ngx_http_headers_module, nginx.org, accessed 7 October 2026.
- 5HSTS Preload List Submission, Chromium, accessed 7 October 2026.
- 6X-Frame-Options, MDN Web Docs, updated 17 September 2026.
- 7Content-Security-Policy-Report-Only, MDN Web Docs, updated 22 March 2026.
- 8X-XSS-Protection, MDN Web Docs, updated 21 August 2026.
- 9Module ngx_http_ssl_module, nginx.org, accessed 7 October 2026.
- 10Mozilla SSL Configuration Generator, Mozilla, accessed 7 October 2026.
- 11Server Side TLS, TLSRef, version 6.0, accessed 7 October 2026.
- 12HTTP Observatory, Mozilla, accessed 7 October 2026.
How to cite this article
SmoothSeen. (2026, October 7). Nginx security headers, HTTPS redirect and HSTS (and the add_header trap). https://smoothseen.com/en/blog/nginx-https-hsts-security-headers/
Keep reading
What is SEO? How search engine optimisation works and how to improve it in 2026
What SEO is, how Google decides which pages to show and a prioritised checklist to improve your rankings with free tools.
.htaccess force HTTPS: redirect to https, enable HSTS and add security headers in Apache
How to force HTTPS in .htaccess or an Apache VirtualHost, roll out HSTS safely and add security headers. Every snippet tested on Apache 2.4.69.
.htaccess gzip and Brotli: browser caching and blocking AI bots in Apache
How to enable gzip and Brotli, set browser caching and block AI training bots in Apache .htaccess without dropping out of ChatGPT search. Tested.