Skip to content
Go back

Security Headers That Break Your Apps

By KingPin 17 min read
Security Headers That Break Your Apps
Contents

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:

RouteWhat it plays
/A plain page with no security headers of its own, like most apps
/appA 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
/legacyAn older page that sets only X-Frame-Options: SAMEORIGIN
/static/a.cssA stylesheet. The nginx config gives this path its own Cache-Control
/missingA 404 from the app
/boomA 500, replaced by the proxy’s own error page

Two probes ran against each of three configs:

  1. headers.sh asks for five paths with curl and counts every copy of every security header.
  2. browser.py loads 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 /app to 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 resultsnginx 1.31.8Caddy 2.11.7Traefik v3.7.17
Security headers on /7 of 77 of 77 of 7
Security headers on /static/a.css07 of 77 of 7
Security headers on the 404HSTS only7 of 77 of 7
Security headers on the 500 error pageHSTS only7 of 77 of 7
The app’s own CSP and X-Frame-Options on /appSent next to oursSent next to oursReplaced by ours
App frames its own / and /legacyBlockedBlockedBlocked
App frames its own /appLoadedLoadedBlocked
Referer sent leaving /appOrigin onlyNothingOrigin only
Hardened resultsnginx 1.31.8Caddy 2.11.7Traefik v3.7.17
HSTS, nosniff, Referrer-Policy, CSP frame-ancestors on all 5 response typesYesYesYes
App’s own no-referrer and SAMEORIGIN on /appKeptKeptKept
App frames its own /, /app, /legacyLoadedLoadedLoaded
Stranger frames any pageBlockedBlockedBlocked
Referer sent leaving /appNothingNothingNothing

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:

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.

CheckBaseline, all 3Trap nginxTrap CaddyTrap TraefikHardened, all 3
Stranger frames /LoadedBlockedBlockedBlockedBlocked
Stranger frames /appBlockedBlockedBlockedBlockedBlocked
Stranger frames /legacyBlockedBlockedBlockedBlockedBlocked
App frames its own /LoadedBlockedBlockedBlockedLoaded
App frames its own /appLoadedLoadedLoadedBlockedLoaded
App frames its own /legacyLoadedBlockedBlockedBlockedLoaded
Referer leaving /appNonehttps://app.example.test:8443/Nonehttps://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:

  1. add_header_inherit merge; makes a location with its own add_header keep the server-level ones. The directive arrived in nginx 1.29.3. The /static/ location in the hardened run still adds Cache-Control, and /static/a.css carries all four security headers.
  2. always on every header puts them on 404s, 500s, and the error page.
  3. The map sends Referrer-Policy only when the app sent none. In our run, add_header skipped the header when the mapped value was empty, so /app goes out with its own no-referrer alone.
  4. proxy_hide_header X-Content-Type-Options drops 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@file

In 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:

Terminal window
cd sumguy-examples/security/reverse-proxy-hardening/03-headers
./headers.sh 443 your.domain.example 203.0.113.10

It 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:

  1. A static or cached path with no security headers.
  2. A 404 or error page with fewer headers than /.
  3. A count of 2 on any header an app also sets.
  4. 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:

  1. Run headers.sh against a static path, a 404, and an app route today. Count the headers.
  2. On nginx 1.29.3 or later, add add_header_inherit merge; and always on every header. On older nginx, use an include snippet.
  3. On Caddy, use > for headers with one correct value, ? for defaults, and + for CSP.
  4. On Traefik, split the headers into a base middleware for every router and a policy middleware for apps that set none.
  5. 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:

  1. Part 4: rate limits, including per-route login and API limits and the real client IP behind a CDN.
  2. 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Home Assistant Blueprints: Write Once

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts