Nginx headers_more vs add_header
This guide is part of the Nginx Security Headers Configuration reference. Nginx ships add_header in the core ngx_http_headers_module, but it only appends, only fires on a fixed set of status codes without the always flag, and silently drops the entire inherited set the moment a child block adds one header of its own. The third-party headers_more module replaces those semantics with more_set_headers, which overwrites rather than appends, applies to every status code by default, can delete headers, and is not subject to add_header’s block-level inheritance reset. This page maps exactly when each is correct.
Configuration Syntax & Exact Values
# Core module — appends a header; fires only on a limited status set
add_header <field-name> "<field-value>" [always];
# headers_more module — sets (replaces) a header on ALL status codes
more_set_headers "<field-name>: <field-value>";
# headers_more — remove a header entirely (add_header cannot do this)
more_clear_headers "<field-name>";
Annotated breakdown:
add_header X-Content-Type-Options "nosniff" always;— the value is quoted whenever it contains spaces or semicolons. Withoutalways, the header is emitted only on200, 201, 204, 206, 301, 302, 303, 304, 307, 308; a403,404,500, or502response ships without it.add_headernever replaces an existing line of the same name — it appends, so a value set upstream plus your own produces two lines.more_set_headers "X-Content-Type-Options: nosniff";— note the single quotedname: valuestring, colon-separated, not the space-separated two-token formadd_headeruses. It applies to responses of any status code with no flag needed, and it overwrites any existing header of that name rather than duplicating it. To scope it to specific codes usemore_set_headers -s '404 500' "...".more_clear_headers "Server";— deletes the named header from the response outright.add_headerhas no removal counterpart; the core module can only suppress the version string viaserver_tokens off, not remove theServerline.
The two directives run in different phases and can coexist, but if both set the same header name the last-applied more_set_headers wins because it operates as a final rewrite of the header list.
more_set_headers replaces and removes across all status codes; add_header only appends and needs always for errors.Server-Side Configuration
Nginx (installing headers_more)
more_set_headers lives in the third-party ngx_headers_more module, which is not compiled into stock Nginx. On Debian/Ubuntu the dynamic module ships as a package:
# Debian / Ubuntu — dynamic module package
sudo apt-get install libnginx-mod-http-headers-more-filter
# Confirm the shared object is present
ls /usr/lib/nginx/modules/ngx_http_headers_more_filter_module.so
Load the dynamic module at the very top of nginx.conf, before any http {} block:
# nginx.conf — must appear in the main context, before http {}
load_module modules/ngx_http_headers_more_filter_module.so;
For a source build, add the module at configure time instead:
./configure --add-dynamic-module=/path/to/headers-more-nginx-module
make && sudo make install
Once loaded, use more_set_headers for a security baseline that must survive on error pages and cannot be duplicated by an upstream:
server {
listen 443 ssl;
server_name app.example.com;
# Applies to 200s AND 404/500/502 with no 'always' flag needed,
# and overwrites any same-named header set upstream.
more_set_headers "Strict-Transport-Security: max-age=63072000; includeSubDomains; preload";
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
# Delete a leaked identity header outright — add_header cannot do this.
more_clear_headers "Server";
more_clear_headers "X-Powered-By";
location / {
proxy_pass http://backend;
}
}
The equivalent with the core module needs always on every line to reach error responses, and cannot delete the Server header:
server {
listen 443 ssl;
server_name app.example.com;
# 'always' is mandatory here — without it these vanish on 4xx/5xx.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# No add_header equivalent for removal; this only blanks the version token.
server_tokens off;
location / {
proxy_pass http://backend;
}
}
Apache
Apache’s mod_headers already merges the two behaviours: Header always set overwrites and covers error responses, and Header unset removes. Apache has no need for a headers_more equivalent. See Apache .htaccess and VirtualHost hardening.
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set X-Content-Type-Options "nosniff"
Header unset X-Powered-By
Diagnostic & Verification Steps
The decisive test is a header on an error response. Request a path that returns 404 and compare.
With add_header but no always — the header is absent on the error:
curl -sI https://app.example.com/does-not-exist | grep -i x-content-type-options
(no output)
With more_set_headers — the header is present on the same 404:
curl -sI https://app.example.com/does-not-exist
HTTP/2 404
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
Confirm removal worked — Server and X-Powered-By must be gone; the count is 0:
curl -sI https://app.example.com/ | grep -Eci 'server:|x-powered-by'
0
Confirm no duplicate — because more_set_headers replaces, the count is exactly 1 even if the upstream set its own value:
curl -sI https://app.example.com/ | grep -ci 'x-content-type-options'
1
Verify the module is loaded — if this returns nothing, more_set_headers is silently ignored and the config test fails at parse time:
sudo nginx -T | grep -i 'load_module.*headers_more'
404, add_header without always drops the header while more_set_headers keeps it — the practical reason to prefer the module for error-page hardening.Edge Cases, Security Implications & Safe Rollback
- Module not loaded, config silently invalid. If
load_moduleis missing or placed insidehttp {}instead of the main context,nginx -tfails withunknown directive "more_set_headers". Always runsudo nginx -tafter adding the module and before reloading; a failed test means the old config keeps serving and your new headers never apply. add_headerinheritance reset does not apply tomore_set_headers. Alocationblock that declares even oneadd_headerdiscards everyadd_headerinherited from the parentserverorhttpblock.more_set_headersdoes not follow this rule — its values persist across nested blocks unless explicitly overridden — which makes it the safer choice when many locations each add path-specific headers. This inheritance trap is detailed in Nginx add_header inheritance in location blocks.- Removal is powerful — do not strip headers the client needs.
more_clear_headerswill happily deleteContent-Type,Content-Length, orLocationif you name them, breaking responses. Restrict removals to identity/version headers such asServerandX-Powered-By. Test with a fullcurl -sIafter anymore_clear_headerschange to confirm no functional header disappeared. - Overwrite hides upstream misconfiguration. Because
more_set_headersreplaces rather than duplicates, an upstream that sets a conflicting Content-Security-Policy is silently overridden. That is usually what you want, but diff the direct-origin response against the proxied one so you know what the backend was emitting before you masked it. The strip-then-add pattern is compared in Nginx add_header vs proxy_hide_header. - Safe rollback. Comment the
more_set_headers/more_clear_headerslines (or theload_moduleline to disable the module wholesale), runsudo nginx -t, thensudo nginx -s reload. Nginx reloads gracefully, so no in-flight request is dropped, andcurl -sIimmediately confirms the previous header set has returned.
more_set_headers; a core-only box on the success path stays on add_header ... always.Conclusion
Roll this out incrementally. In staging, load the headers_more module, add the more_set_headers baseline plus any more_clear_headers strips, and prove the diff with curl -sI on both a 200 and a 404. Start HSTS with a short max-age and drop preload until every host serves HTTPS, then raise max-age and add preload once verified. Only after sudo nginx -t passes and the staging diff is clean do you promote the same block to full production behind a graceful nginx -s reload.
Frequently Asked Questions
Do I still need the always flag with more_set_headers?
No. always is an add_header concept for reaching 4xx/5xx responses. more_set_headers applies to every status code by default, so there is no flag to add — that all-status coverage is the main reason to use it for security headers.
Can add_header remove a header like more_clear_headers does?
No. add_header only appends; it has no removal mode. To delete a header in core Nginx you either use proxy_hide_header for upstream-set headers or install headers_more for more_clear_headers. server_tokens off only blanks the version string in the Server line, it does not remove the line.
Why does my more_set_headers line cause nginx -t to fail?
The headers_more module is not compiled into stock Nginx, so the directive is unknown until you install the module and add load_module modules/ngx_http_headers_more_filter_module.so; to the main context of nginx.conf, above http {}. Placing load_module inside http {} also fails the test.
If both add_header and more_set_headers set the same header, which wins?
more_set_headers wins. It runs as a final rewrite of the response header list and overwrites any same-named value, whereas add_header merely appended earlier. Relying on both for one header is fragile — pick one directive per header.
Does more_set_headers avoid the add_header inheritance reset?
Yes. A location that uses add_header discards all inherited add_header directives, but more_set_headers values are not wiped by a child block adding its own header. This makes the module safer for multi-location configs where headers are set at several levels.