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.
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.
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.
- The upstream does not set it. A single
add_headeris enough — there is nothing to strip, soproxy_hide_headerwould be a no-op. - The upstream sets it and the value is fine. Do nothing. Nginx forwards the upstream header unchanged, and adding your own identical line only risks a duplicate.
- The upstream sets it but you want a different value. This is the only case that needs both directives:
proxy_hide_headerto drop the backend’s copy, thenadd_headerto write yours. Skipping the hide leaves two conflicting lines on the wire.
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
- 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.