In one line: configuring "trusted proxies" doesn't make a header trustworthy if that proxy never rewrites it. The real fix is making the headers your framework reads match the headers your front proxy overwrites or strips.
On 2026-11-05 Lauri Koskela published "Forwarded Without Question: How Web Frameworks Overtrust the Forwarded Header", and it got picked up on Lobsters and elsewhere. It started with a small oddity while he was deploying an HTTPS reverse proxy on Envoy: Envoy handles X-Forwarded-For, but does nothing with the standardized Forwarded header (RFC 7239). So what happens to frameworks that do read Forwarded?
The findings are concrete. Rails, Jetty, Spring Boot (before 4.2) and Reactor Netty read Forwarded in near-default configurations, and prefer it over X-Forwarded-*. Meanwhile, every major reverse proxy he tested (Envoy Gateway, ingress-nginx, Traefik, Apache, Caddy, Kong) passes Forwarded through untouched; only HAProxy can handle it, and only when explicitly enabled. So a Forwarded: for=127.0.0.1 that the client wrote itself reaches the app looking like it came "via a trusted proxy".
The projects responded after his reports. Rails 8.1.4.1 extended its IP-spoofing check to cover Forwarded. Spring Boot 4.2 now parses only X-Forwarded-* by default. Spring Framework 7.1 and Reactor Netty 1.4 used without Boot now require you to choose a header family explicitly. Jetty made the behaviour easier to configure but kept its defaults. The gap between "implemented per spec" and "what real proxies do" is being closed right now.
When there's a proxy, load balancer or CDN in front of your app, the TCP peer your app sees is the nearest proxy. The only way to learn the real client IP, the original host name and the original scheme (was it https?) is for the proxy to tell you in headers. There are two families of such headers:
X-Forwarded-* family: X-Forwarded-For (XFF), X-Forwarded-Host, X-Forwarded-Proto. Non-standard, but effectively universal.Forwarded (RFC 7239): the standard version that folds for=, host=, proto= and by= into one header. Cleaner on paper, rarely supported in practice.The core point is that headers are data anyone can write. A client can send any Forwarded or X-Forwarded-For it likes. A header value is trustworthy only when someone trustworthy actually wrote (overwrote) it. And these values feed security decisions: IP allowlists, rate limits, audit logs, password-reset link generation, HTTP-to-HTTPS redirects.
Many frameworks have a "trusted proxies" setting: forwarding headers are read only when the TCP peer is a configured proxy IP. That protects against requests that reach the app directly, bypassing the proxy.
It does not guarantee who wrote the header contents. If a trusted proxy passes Forwarded through, the client's value arrives labelled "from a trusted proxy". Koskela calls this a confused deputy: a privileged intermediary carrying hostile input on someone else's behalf. Express's docs carry the same warning: if you set trust proxy to true, the last trusted reverse proxy must remove or overwrite X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto.
The typical attack looks like this:
Client ──(Forwarded: for=127.0.0.1)──▶ Proxy ──▶ App
│
├─ X-Forwarded-For: 1.2.3.4 ← written correctly by the proxy
└─ Forwarded: for=127.0.0.1 ← passed through
App: "prefer Forwarded" → remote_ip = 127.0.0.1
The proxy writes the XFF it's responsible for correctly. But if the app prefers Forwarded, the client's value wins. The same applies to host= and proto=.
How implementations behaved (versions as tested in the article):
Forwarded: for support. There was already a check that raises IpSpoofAttackError when Client-IP and XFF are both present, but before 8.1.4.1 it didn't include Forwarded. Rails doesn't use host, so the impact is limited to IP spoofing.