Added Is Not the Same as Applied
We added seven security headers to nginx, Caddy, and Traefik the way most guides show it: one block of add_header lines, one header block, one middleware. Then we asked each proxy for five kinds of response and let a real Chromium load the pages. On nginx, a static file got zero security headers, HSTS included. On Traefik, the app’s own Content-Security-Policy and X-Frame-Options were replaced by ours. On all three, the app could no longer frame its own / and /legacy pages.
The starting point was not safe either. With only HSTS in place, a stranger’s site could frame / on all three proxies. Adding headers was the right instinct. Adding them blind is how you swap one hole for three.
This is Part 3 of a series. What Your Reverse Proxy Leaks by Default covered banners and catch-all servers. Reverse Proxy TLS: What the A+ Hides covered ciphers, curves, and HSTS. The baseline here is where Part 2 stopped: HSTS is the only security header the proxy adds.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/reverse-proxy-hardening/03-headers
How We Tested
One machine, local Docker, every port bound to 127.0.0.1, one throwaway certificate shared by all three proxies. The versions: nginx 1.31.8, Caddy 2.11.7, Traefik v3.7.17, with Chromium 156 driven by Playwright 1.58.0.
The backend is a small Python app with one route for each case a proxy has to handle:
| Route | What it plays |
|---|---|
/ | A plain page with no security headers of its own, like most apps |
/app | A page that sets its own CSP frame-ancestors 'self', X-Frame-Options: SAMEORIGIN, nosniff, and Referrer-Policy: no-referrer, like apps that ship their own security headers |
/legacy | An older page that sets only X-Frame-Options: SAMEORIGIN |
/static/a.css | A stylesheet. The nginx config gives this path its own Cache-Control |
/missing | A 404 from the app |
/boom | A 500, replaced by the proxy’s own error page |
Two probes ran against each of three configs:
headers.shasks for five paths with curl and counts every copy of every security header.browser.pyloads an embed page to see whether the app can frame its own pages, loads a stranger’s page on another hostname to see whether it can frame them, and then clicks from/appto another site to see what Referer arrives.
The three configs: baseline (Part 2’s configs), trap (seven headers added the common way), and hardened (the fixes below). The limits are in the box at the top. The big one: this is one synthetic app. Your apps set different headers, so treat the mechanisms as the result and the exact counts as one example.
The Scorecard
The trap added these seven headers: HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy that blocks camera, microphone, and geolocation, Content-Security-Policy: default-src 'self', and X-XSS-Protection: 1; mode=block.
| Trap results | nginx 1.31.8 | Caddy 2.11.7 | Traefik v3.7.17 |
|---|---|---|---|
Security headers on / | 7 of 7 | 7 of 7 | 7 of 7 |
Security headers on /static/a.css | 0 | 7 of 7 | 7 of 7 |
| Security headers on the 404 | HSTS only | 7 of 7 | 7 of 7 |
| Security headers on the 500 error page | HSTS only | 7 of 7 | 7 of 7 |
The app’s own CSP and X-Frame-Options on /app | Sent next to ours | Sent next to ours | Replaced by ours |
App frames its own / and /legacy | Blocked | Blocked | Blocked |
App frames its own /app | Loaded | Loaded | Blocked |
Referer sent leaving /app | Origin only | Nothing | Origin only |
| Hardened results | nginx 1.31.8 | Caddy 2.11.7 | Traefik v3.7.17 |
|---|---|---|---|
HSTS, nosniff, Referrer-Policy, CSP frame-ancestors on all 5 response types | Yes | Yes | Yes |
App’s own no-referrer and SAMEORIGIN on /app | Kept | Kept | Kept |
App frames its own /, /app, /legacy | Loaded | Loaded | Loaded |
| Stranger frames any page | Blocked | Blocked | Blocked |
Referer sent leaving /app | Nothing | Nothing | Nothing |
nginx: The Location That Ate Your Headers
The trap config is what most guides print. Seven add_header lines in the server block, and a /static/ location that adds Cache-Control:
add_header Strict-Transport-Security "max-age=63072000" always;add_header X-Content-Type-Options "nosniff";add_header X-Frame-Options "DENY";# ...four more, none with "always"
location /static/ { proxy_pass http://backend:8080; add_header Cache-Control "public, max-age=86400";}Ask for /static/a.css and nothing security-related comes back. HSTS is gone too, even though it has always. The nginx docs state the rule: add_header directives are inherited from the previous level “if and only if there are no add_header directives defined on the current level.” One Cache-Control line in a location replaces every header you set above it. The baseline did not show this because Part 2’s config has no add_header inside any location. The moment you add a caching line, the server-level headers disappear for that path.
The 404 and the 500 error page lost six of seven headers for a second reason. Without always, nginx adds a header only on a fixed list of status codes (200, 201, 204, 206, 301, 302, 303, 304, 307, 308). HSTS has always, so it survives. The other six do not. The error page in our trap config returns 500 in the run, not the 502 its return line suggests, and it carried HSTS only.
Caddy and Traefik had no such problem. Every response type got all seven headers, error pages included.
Two Copies of Everything
On /app, the app sends its own headers and the proxy adds seven more. nginx and Caddy forward both sets. In the trap run, /app carried:
- two
X-Frame-Optionsvalues,DENYandSAMEORIGIN - two
Content-Security-Policyheaders - two
Referrer-Policyheaders,no-referrerandstrict-origin-when-cross-origin - two
X-Content-Type-Options: nosnifflines
Duplicate nosniff is harmless. The rest is a conflict, and the browser settles it, not you. Two CSP headers are both enforced. Two Referrer-Policy values resolve to the last valid one. Two X-Frame-Options values are the case we only observed: with DENY and SAMEORIGIN together on /legacy, Chromium blocked the frame. (headers.sh does not request /legacy, so both values there come from the configs, the proxy’s DENY and the app’s SAMEORIGIN.) We did not trace why, so read that as an observation about one browser.
Traefik: The Middleware That Overwrites
Traefik’s headers middleware does not add a second copy. It sets the header, and the app’s copy is gone. On /app in the trap run, the app’s frame-ancestors 'self' CSP and its SAMEORIGIN header were replaced by default-src 'self' and DENY. The app’s no-referrer was replaced by strict-origin-when-cross-origin.
That avoids duplicate headers, and it also removes the app’s own decisions. The app author picked those headers for a reason, usually after a bug report. The proxy now overrules them with a blanket policy that knows nothing about the app.
What Chromium Did With Them
Header counts are one layer. We also loaded the pages in Chromium 156 and recorded what happened.
| Check | Baseline, all 3 | Trap nginx | Trap Caddy | Trap Traefik | Hardened, all 3 |
|---|---|---|---|---|---|
Stranger frames / | Loaded | Blocked | Blocked | Blocked | Blocked |
Stranger frames /app | Blocked | Blocked | Blocked | Blocked | Blocked |
Stranger frames /legacy | Blocked | Blocked | Blocked | Blocked | Blocked |
App frames its own / | Loaded | Blocked | Blocked | Blocked | Loaded |
App frames its own /app | Loaded | Loaded | Loaded | Blocked | Loaded |
App frames its own /legacy | Loaded | Blocked | Blocked | Blocked | Loaded |
Referer leaving /app | None | https://app.example.test:8443/ | None | https://app.example.test:10443/ | None |
Read the framing rows in order.
Baseline. The app’s own headers protect /app and /legacy from a stranger. / has none, so a stranger’s site can frame it. That is the gap the trap tries to close, and it does: X-Frame-Options: DENY blocks the stranger on every path.
Trap. The same DENY also blocks the app. Anything that embeds its own views, such as a dashboard with inline panels, an admin page with a preview pane, or a login iframe, loses the embed on / and /legacy. /app still self-frames on nginx and Caddy because the app’s own CSP frame-ancestors 'self' is in the response, and a CSP frame-ancestors directive wins over X-Frame-Options when both are present. We saw that directly: the /app response carried DENY and SAMEORIGIN plus the app’s CSP, and Chromium framed it anyway. On Traefik, the app’s CSP was overwritten, so DENY stood alone and /app was blocked.
Referer. /app sets Referrer-Policy: no-referrer, so leaving it should send nothing. With the trap, nginx and Traefik sent the origin (https://app.example.test:8443/ and https://app.example.test:10443/) to the other site. Caddy sent nothing.
The cause is that a browser given several Referrer-Policy values uses the last valid one, and the kit’s raw dump of /app (app-raw-<proxy>.txt) shows the order on the wire. nginx sent the app’s no-referrer first and its own strict-origin-when-cross-origin last, so the proxy’s value won. Caddy sent its own value first and the app’s no-referrer last, and no-referrer won. That order matches Caddy’s docs: a plain header operation runs immediately, before reverse_proxy adds the upstream headers. Traefik sent only its own value.
strict-origin-when-cross-origin Is Already the Default
MDN says strict-origin-when-cross-origin is the default policy when none is specified or the value is invalid, per a November 2020 spec revision. Before that the default was no-referrer-when-downgrade.
So in a current browser the proxy header adds nothing for apps that set no policy. It does one thing: it overrides an app that chose something stricter. That is exactly what happened to /app on nginx and Traefik. A guide that tells you to add this header is describing a protection that was already there, with a side effect.
The Hardened Configs
The fixes differ per proxy because each one has a different rule for headers the app already set.
nginx
map $upstream_http_referrer_policy $referrer_policy { "" "strict-origin-when-cross-origin"; default "";}
server { # ... add_header_inherit merge;
add_header Strict-Transport-Security "max-age=63072000" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy $referrer_policy always; add_header Content-Security-Policy "frame-ancestors 'self'" always;
proxy_hide_header X-Powered-By; proxy_hide_header X-Content-Type-Options; # ...}Four changes, each tied to a failure above:
add_header_inherit merge;makes a location with its ownadd_headerkeep the server-level ones. The directive arrived in nginx 1.29.3. The/static/location in the hardened run still addsCache-Control, and/static/a.csscarries all four security headers.alwayson every header puts them on 404s, 500s, and the error page.- The
mapsends Referrer-Policy only when the app sent none. In our run,add_headerskipped the header when the mapped value was empty, so/appgoes out with its ownno-referreralone. proxy_hide_header X-Content-Type-Optionsdrops the app’s copy, so only ours goes out. Nosniff has one value, so replacing it is safe.
On nginx older than 1.29.3, put the four add_header lines in a snippet and include it in every location that adds a header of its own. It works the same way and costs you a line per location.
Caddy
header { >Strict-Transport-Security "max-age=63072000" >X-Content-Type-Options "nosniff" ?Referrer-Policy "strict-origin-when-cross-origin" +Content-Security-Policy "frame-ancestors 'self'"}Caddy’s docs describe four prefixes. > sets the field and defers the operation until the response goes to the client, so it replaces the app’s copy. ? writes the field only if it does not exist yet, which is the “only if the app sent none” behavior nginx needed a map for. + adds a field next to an existing one. By default, operations run immediately, and Caddy defers them automatically when a field is deleted with - or defaulted with ?.
That gives HSTS and nosniff the replace treatment, Referrer-Policy the default treatment, and CSP the add treatment. On /app, Caddy sent the app’s no-referrer alone and two CSP headers, both frame-ancestors 'self'.
Traefik
Traefik’s headers middleware always overwrites, and it has no “only if absent” option. So the fix is two middlewares:
http: middlewares: base-headers: headers: contentTypeNosniff: true policy-headers: headers: referrerPolicy: "strict-origin-when-cross-origin" contentSecurityPolicy: "frame-ancestors 'self'"base-headers carries only nosniff, which has one correct value, and goes on every router. policy-headers goes only on routers whose app sets no policy of its own. In the kit, one backend plays two apps, so /app and /legacy get a second router without policy-headers:
- traefik.http.routers.app.middlewares=strip-banners@file,hsts@file,base-headers@file,policy-headers@file- traefik.http.routers.app-own.rule=Host(`app.example.test`) && (Path(`/app`) || Path(`/legacy`))- traefik.http.routers.app-own.middlewares=strip-banners@file,hsts@file,base-headers@fileIn real life that split is one router per app. The cost is that you must know which of your apps set their own policy. That is a per-app decision anyway.
What the hardened run showed
All five response types (/, /app, /static/a.css, the 404, the 500) carried HSTS, nosniff, Referrer-Policy, and CSP frame-ancestors on all three proxies. The app’s own no-referrer and SAMEORIGIN survived on /app. In Chromium, every page could frame itself, no stranger could frame any page, and no Referer left /app.
Why a Second CSP Header Is Safe
CSP is the one header the nginx and Caddy configs append instead of replacing. That is deliberate. MDN states that CSP allows multiple policies and that the Content-Security-Policy header can appear more than once. The browser enforces every policy it receives, so a second policy can only tighten what the first allows.
Our frame-ancestors 'self' policy therefore cannot loosen an app’s own policy. If the app sends frame-ancestors 'none', the page stays unframeable. If the app sends no CSP, ours is the only one. Compare that with X-Frame-Options: DENY, which cannot say “my own origin may frame this.”
The cost: an app that deliberately lets another origin frame it, such as a dashboard you embed on a different hostname, gets blocked by our 'self' policy, because the browser enforces both. Skip the proxy CSP for that app, or on Traefik leave policy-headers off its router. The kit did not test this case.
Headers We Left Out
The hardened configs send four headers. The trap sent seven. Three are gone on purpose, and a fourth changed shape.
X-Frame-Options. The proxy no longer sends it. The CSP frame-ancestors directive does the same job with more precision, and the /app result shows the CSP overriding conflicting X-Frame-Options values in Chromium. MDN points to frame-ancestors as the replacement, and OWASP says it obsoletes the header for supporting browsers. An app that sends its own SAMEORIGIN, like /legacy, still works, because the proxy adds nothing that conflicts with it.
X-XSS-Protection. MDN marks it deprecated and non-standard, and warns that in some cases it can create XSS vulnerabilities in otherwise safe sites. OWASP’s advice is to not set it, or to set it to 0. The trap sent 1; mode=block, which is the wrong direction.
Permissions-Policy. This one is per app. A blanket camera=(), microphone=(), geolocation=() is fine for a blog and breaks a video-call app or an NVR. A proxy cannot know which you run on that hostname, and a camera that stops working at 2 AM is a bad way to learn it.
A blanket CSP. default-src 'self' replaced or stacked onto the app’s own policies. Apps with inline scripts break under it, and the framing result shows what happens when the proxy overrules the app. Real CSP belongs to the app, because only the app knows which scripts and styles it loads. The proxy can safely add one narrow directive, frame-ancestors, because that one needs no knowledge of the app’s content.
Part 1 Carry-Overs
The earlier findings did not change. Traefik still passes the app’s stack trace on /boom, and Caddy’s port-80 redirect still says Server: Caddy. Part 3’s headers neither fix nor break either one.
Run It Against Your Own Proxy
The kit’s headers.sh takes the HTTPS port and optional host and address:
cd sumguy-examples/security/reverse-proxy-hardening/03-headers./headers.sh 443 your.domain.example 203.0.113.10It prints five blocks, one per path, with a count in front of every header line. Replace the paths with routes from your own app. Look for these:
- A static or cached path with no security headers.
- A 404 or error page with fewer headers than
/. - A count of 2 on any header an app also sets.
- The app’s own policy headers missing, which means the proxy overwrote them.
The browser.py script needs Playwright and is wired to the lab: it maps the hostname to 127.0.0.1. To run the framing and Referer checks on your own config, load that config into the kit’s compose file instead of your live proxy.
The SumGuy Take
Security headers fail quietly. A missing header gives you no error, and a header that overrides your app gives you a bug report from someone else. The practical order for a home lab:
- Run
headers.shagainst a static path, a 404, and an app route today. Count the headers. - On nginx 1.29.3 or later, add
add_header_inherit merge;andalwayson every header. On older nginx, use an include snippet. - On Caddy, use
>for headers with one correct value,?for defaults, and+for CSP. - On Traefik, split the headers into a base middleware for every router and a policy middleware for apps that set none.
- Send HSTS, nosniff, a default Referrer-Policy, and CSP
frame-ancestors. Drop X-XSS-Protection, and leave Permissions-Policy to the apps that need one.
What Comes Next
The rest of the series:
- Part 4: rate limits, including per-route login and API limits and the real client IP behind a CDN.
- Part 5: hiding admin paths, including the Traefik dashboard, the Caddy admin API, and forward auth.
Common Questions
Is X-Frame-Options deprecated?
Not formally, but CSP frame-ancestors replaces X-Frame-Options wherever CSP is supported. OWASP says frame-ancestors obsoletes X-Frame-Options for supporting browsers, and MDN points to it as the replacement. In our Chromium 156 run, a page that sent X-Frame-Options DENY and SAMEORIGIN alongside CSP frame-ancestors ‘self’ still framed itself, so the CSP won. Keep X-Frame-Options only if an app already sends it.
Should I set Permissions-Policy on my reverse proxy?
No. A proxy cannot know which browser features an app needs. A blanket camera=(), microphone=() policy protects a blog and breaks a video-call or NVR app on the same hostname. Set Permissions-Policy in each app that benefits from it, or per router when one hostname serves one app.
Does add_header_inherit work on nginx stable?
Yes, on the stable 1.30 line. nginx added add_header_inherit in 1.29.3, and the 1.30 changelog includes it. Stable is 1.30.6 as of November 2026. The 1.28 legacy line and older lack it, so there you put your add_header lines in a snippet and include it in every location that adds a header.
Do I still need X-XSS-Protection?
No. MDN marks X-XSS-Protection deprecated and non-standard, and warns it can create XSS vulnerabilities in otherwise safe sites. OWASP says to not set it or to set it to 0. Use a Content-Security-Policy that disables inline JavaScript. Remove the header from copy-pasted configs.
Can a second Content-Security-Policy header weaken the app’s own CSP?
No. MDN says CSP allows multiple policies, including repeated Content-Security-Policy headers, and the browser enforces each one. A second policy can only add restrictions. That is why the nginx and Caddy configs append a frame-ancestors policy next to the app’s CSP instead of replacing it.