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:
proxy_hide_header X-Powered-By;— runs during upstream response parsing. The named header is stripped from the backend response before Nginx builds the client response. It takes no value; it only suppresses. It applies only toproxy_passupstreams (usefastcgi_hide_headerfor PHP-FPM,uwsgi_hide_headerfor uWSGI).add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;— runs during the output filter phase, appending the line to the final response. The quoted value is mandatory when it contains spaces or semicolons. The trailingalwaysmakes the header emit on error responses (4xx/5xx) too; without it the header is attached only to200, 201, 204, 206, 301, 302, 303, 304, 307, 308.
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
- Duplicate headers from skipping the strip. If the upstream sets
X-Frame-Optionsand you onlyadd_headeryour own, the response carries the header twice. Browsers may apply the first, the most restrictive, or reject the pair. Alwaysproxy_hide_headerbefore re-adding any header the upstream might set. - Upstream override you forgot about. A backend framework can set
Content-Security-PolicyorStrict-Transport-Securitysilently. Diff the direct-origincurlagainst the proxied one to find every header the upstream emits, then decide per header whether to pass it through or strip-and-replace. - Inheritance reset inside the location. Because the security headers live in this
location, any other location that adds even oneadd_headerloses all of them. Keep the baseline in anincludefile and reference it in each location rather than copy-pasting. See Nginx add_header inheritance in location blocks. - Wrong protocol family.
proxy_hide_headeronly affectsproxy_pass. For FastCGI/PHP-FPM usefastcgi_hide_header; for uWSGI useuwsgi_hide_header. Stripping with the wrong directive silently does nothing. - Safe rollback. Comment the changed directives, run
nginx -t, reload withnginx -s reload, and re-runcurl -sIto confirm the upstream’s original headers return. Nginx reloads gracefully, so no in-flight requests are dropped.
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.
Related
- Nginx Security Headers Configuration — the full security-headers baseline this builds on.
- Nginx add_header inheritance in location blocks — why headers vanish per path.
- HSTS deep dive — the
max-ageand preload values used above.