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.

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>

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.