Nginx add_header vs proxy_hide_header Explained

This guide is part of the Nginx Security Headers Configuration reference. add_header and proxy_hide_header solve opposite halves of the same problem when Nginx fronts an upstream: one adds a header to the response going out to the client, the other removes a header coming in from the backend. Confusing them — or using only one when you need both — produces either leaked upstream metadata or duplicate, conflicting header lines that browsers resolve in your least favor.

Configuration Syntax & Exact Values

# Remove a header the upstream sent before Nginx forwards the response
proxy_hide_header <field-name>;

# Append a header to the response Nginx sends to the client
add_header <field-name> "<field-value>" [always];

Annotated breakdown:

The two directives do not interact directly. proxy_hide_header clears the upstream’s version; add_header writes yours. To replace a header you run both: hide the incoming one, then add the outgoing one.

How the two directives compare

Before wiring both into a config, it helps to see exactly where they diverge. They fire in different request phases, take different arguments, and — the detail that trips people up — inherit differently across the http, server, and location scopes.

Comparison matrix of add_header versus proxy_hide_headerA five-row matrix contrasting the two directives across direction, execution phase, argument, scope inheritance, and protocol support. Aspect add_header proxy_hide_header Direction Out to client In from upstream Runs during Output filter Upstream parse Takes a value Yes (quoted) No, name only Scope inherit Replaces parent Cumulative Protocol form All backends proxy_pass only
The two directives differ on every axis that matters: direction, execution phase, whether they carry a value, how they inherit across scopes, and which upstream protocols they cover.

Because the two run in separate phases, their textual order in the config is irrelevant: Nginx has already parsed the upstream response (and applied every proxy_hide_header) long before the output filter reaches add_header. You can list them in any order and the result is identical. The inheritance row is the sharp edge — add_header in a child scope silently discards the parent’s entire set, while proxy_hide_header accumulates down the tree, so a stray add_header in one location is far likelier to cause a regression than a stray hide.

Server-Side Configuration

Nginx

The canonical pattern strips informational headers leaked by the upstream, then re-adds a hardened security baseline at the Nginx layer:

server {
    listen 443 ssl;
    server_name app.yourdomain.com;

    location / {
        proxy_pass http://backend_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # Strip leaked upstream metadata
        proxy_hide_header X-Powered-By;
        proxy_hide_header Server;
        proxy_hide_header X-AspNet-Version;

        # Replace an upstream-set security header with our own value:
        # hide the upstream's copy first to avoid a duplicate line...
        proxy_hide_header X-Frame-Options;
        # ...then emit the hardened value. `always` keeps it on 4xx/5xx too.
        add_header X-Frame-Options "SAMEORIGIN" always;

        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    }
}

Note that placing add_header inside this location discards any add_header inherited from the server or http block — Nginx replaces rather than merges. Every header you want on this path must be declared here. This is covered in Nginx security headers configuration.

Apache

Apache splits the same job across mod_headers: Header unset removes an upstream header and Header always set adds one, and unlike Nginx these merge across scopes rather than replacing the parent set.

<Location "/">
    Header unset X-Powered-By
    Header unset Server
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
</Location>

Choosing the right directive

Every header falls into one of three cases, and the right combination follows directly from whether the upstream already emits it and whether you trust the value it sends.

Decision tree for choosing between add_header and proxy_hide_headerA branching flow that starts from whether the upstream emits the header and ends at add_header only, no directive, or the combined hide-and-add pattern. Upstream emits this header? no yes add_header only set your value directly Keep the upstream value? yes no No directive pass it through proxy_hide_header + add_header
The strip-and-re-add pair is needed in exactly one branch: when the upstream emits the header and you do not trust its value.

The trap is the transition into that last branch. A value that was safe when you wrote the config can change after a backend upgrade — framework defaults for X-Frame-Options, Content-Security-Policy, and even Strict-Transport-Security drift between releases. A header that needed no handling last quarter may start arriving with a permissive value, so re-run the direct-origin capture after every upstream deploy rather than trusting a one-time audit.

When one directive genuinely replaces a header

add_header and proxy_hide_header are two directives precisely because stock Nginx has no single edit-in-place primitive. The third-party headers-more module (ngx_http_headers_more_module) supplies one: more_set_headers "X-Frame-Options: SAMEORIGIN" removes any existing copy and writes the new value in a single statement. Unlike add_header, it applies to every status code without an always flag and does not wipe the inherited set on location entry. Check whether it is compiled in with:

nginx -V 2>&1 | tr ' ' '\n' | grep -i headers-more

If that prints --add-module=.../headers-more-nginx-module, you can collapse the strip-and-re-add pair into one more_set_headers line. Stock builds without the module must keep using the proxy_hide_header + add_header pattern shown above.

Diagnostic & Verification Steps

Capture the headers before and after the change to prove both the strip and the re-add worked.

Before — what the upstream leaks (request the origin directly, bypassing the proxy):

curl -sI http://127.0.0.1:8080/
HTTP/1.1 200 OK
Server: Express
X-Powered-By: Express
X-Frame-Options: ALLOWALL

After — through Nginx with the config above:

curl -sI https://app.yourdomain.com/
HTTP/2 200
x-frame-options: SAMEORIGIN
strict-transport-security: max-age=63072000; includeSubDomains
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin

X-Powered-By and the upstream Server value are gone, and X-Frame-Options now reads SAMEORIGIN instead of the upstream’s ALLOWALL, appearing exactly once.

Confirm no duplicate — the count must be 1:

curl -sI https://app.yourdomain.com/ | grep -ci 'x-frame-options'

Audit the resolved config for every strip/add directive:

sudo nginx -T | grep -E 'add_header|proxy_hide_header'

Edge Cases, Security Implications & Safe Rollback

Reverse-proxy header flow: strip upstream then re-add hardened header Flow showing an upstream X-Frame-Options ALLOWALL header stripped by proxy_hide_header at Nginx, then a hardened SAMEORIGIN value re-added with add_header before reaching the browser. Upstream XFO: ALLOWALL response Nginx proxy_hide_header XFO strips ALLOWALL add_header XFO SAMEORIGIN hardened Browser XFO: SAMEORIGIN
The upstream's X-Frame-Options: ALLOWALL is removed by proxy_hide_header at Nginx, then a hardened SAMEORIGIN value is re-added with add_header before the response reaches the browser.

Frequently Asked Questions

Can I change a header value with add_header alone? No. add_header appends; it never edits or removes an existing line. If the upstream already set the header, adding your own produces two lines. Strip the upstream’s with proxy_hide_header first, then add_header your value.

Does proxy_hide_header need the always flag? No. always belongs to add_header. proxy_hide_header takes only a header name and applies to every proxied response regardless of status code.

Why does my stripped header still show up in logs? proxy_hide_header removes the header from the client-facing response, not from the upstream connection Nginx saw. The value is still available to log via $upstream_http_<name>, which is useful for confirming the strip is doing its job.

Does this work for PHP-FPM or uWSGI backends? Not with proxy_hide_header, which is proxy_pass-only. Use fastcgi_hide_header for PHP-FPM and uwsgi_hide_header for uWSGI; add_header works the same across all of them.

Conclusion

Roll this out incrementally. In staging, capture the upstream’s headers with a direct curl, add the proxy_hide_header strips and add_header security values, and verify the before/after diff and a duplicate count of 1. Begin HSTS with a short max-age, raise it once every host serves HTTPS, and only then promote the same proxy_hide_header + add_header block to full production behind nginx -t and a graceful reload.