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:

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.

Capability matrix: add_header versus more_set_headers A matrix comparing add_header and more_set_headers across appends, replaces, removes, all-status-code coverage, and inheritance behaviour. Behaviour by directive add_header more_set_headers Appends a header yes (always) no — it replaces Replaces existing value no yes Removes a header no yes (more_clear_headers) Covers 4xx / 5xx only with always yes, by default Ships with Nginx core yes no — third-party module
Where the two directives diverge: 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 workedServer 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'
Header emission on a 404 response by directive Two response paths for a 404: add_header without always omits the security header, while more_set_headers includes it. Same 404 request, two directives GET /missing 404 add_header (no always) header stripped on 404 no XCTO header 404 GET /missing more_set_headers applies to all codes XCTO: nosniff
On a 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

Decision tree: choose add_header or more_set_headers A decision tree routing on whether you must remove a header, cover error responses, or avoid recompiling Nginx, to pick between the two directives. Need to remove a header? yes no more_set_headers Header on 4xx/5xx too? yes no Can install a module? add_header yes no more_set_headers add_header ... always
Removal or all-status coverage without recompiling points to 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.