Forwarded vs X-Forwarded-For (tested)

Forwarded (RFC 7239, 2014) is the standard header for passing the original client address, scheme and host through a proxy. X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host are the older, non-standard headers that do the same job. We sent both kinds to Express 5.2.1 and Werkzeug 3.1.9. Neither framework read Forwarded at all, so in practice your proxy still has to send the X-Forwarded-* headers.

1. The two formats side by side

The same request, after one proxy, written both ways (syntax from RFC 7239, not measured):

X-Forwarded-For: 203.0.113.7
X-Forwarded-Proto: https
X-Forwarded-Host: shop.example.com

Forwarded: for=203.0.113.7;proto=https;host=shop.example.com
InformationX-Forwarded-* headerForwarded parameter
Original client IPX-Forwarded-Forfor=
Original scheme (http/https)X-Forwarded-Protoproto=
Original Host headerX-Forwarded-Hosthost=
The proxy's own interface(none)by=

Rules from RFC 7239 (not measured):

2. Express 5.2.1 ignores Forwarded

A route that returned req.ip, req.ips, req.protocol and req.hostname. The connection always came from 127.0.0.1. With app.set('trust proxy', true):

Request headersreq.ipreq.protocolreq.hostname
(none)127.0.0.1httplocalhost
X-Forwarded-For: 203.0.113.7, X-Forwarded-Proto: https, X-Forwarded-Host: shop.example.com203.0.113.7httpsshop.example.com
Forwarded: for=203.0.113.7;proto=https;host=shop.example.com127.0.0.1httplocalhost

The Forwarded header changed nothing under any trust proxy setting we tried (false, true, 1, 2, 'loopback'). The source agrees: the forwarded 0.2.0 package that Express uses parses only req.headers['x-forwarded-for'], and req.protocol / req.host read only X-Forwarded-Proto / X-Forwarded-Host. If your proxy sends only Forwarded, Express sees the proxy's IP and plain http.

3. Werkzeug / Flask ProxyFix ignores it too

Werkzeug 3.1.9 with ProxyFix(app, x_for=1, x_proto=1, x_host=1):

Request headersremote_addrschemehost
X-Forwarded-For: 203.0.113.7 + Proto https + Host shop.example.com203.0.113.7httpsshop.example.com
Forwarded: for=203.0.113.7;proto=https;host=shop.example.com127.0.0.1httplocalhost

The string Forwarded (as a header name) does not appear anywhere in the installed Werkzeug package. Two more things we measured:

4. The spoofing problem: trust proxy true

A proxy that appends (nginx's $proxy_add_x_forwarded_for, for example) keeps whatever the client sent and adds the real address on the right. So a client that sends X-Forwarded-For: 6.6.6.6 through one proxy produces X-Forwarded-For: 6.6.6.6, 198.51.100.2. We sent that header to Express:

trust proxyreq.ipreq.ips
false (default)127.0.0.1[]
true6.6.6.6 (the client's fake)["6.6.6.6", "198.51.100.2"]
1198.51.100.2 (the real client)["198.51.100.2"]
'loopback'198.51.100.2["198.51.100.2"]
'10.0.0.0/8' (does not match the 127.0.0.1 peer)127.0.0.1[]

true trusts every hop and takes the leftmost entry, which the client controls. Rate limits, IP allowlists and logs keyed on req.ip can then be bypassed with one header. Use the number of proxies you actually run (1 for a single nginx or load balancer) or the proxy's address or subnet.

A 3-entry chain 1.2.3.4, 203.0.113.7, 198.51.100.2 gave req.ip = 1.2.3.4 with true, 198.51.100.2 with 1, and 203.0.113.7 with 2. Werkzeug's x_for=1 and x_for=2 gave the same results as Express 1 and 2. With x_for=2 and only one entry in the header, Werkzeug ignored the header and kept 127.0.0.1; Express 2 used the single entry.

Neither framework validated the value. X-Forwarded-For: not-an-ip became req.ip = "not-an-ip" in Express (true and 1) and remote_addr = "not-an-ip" with ProxyFix x_for=1. If you store the IP in a database column of type inet, validate it first.

5. Proto and Host lists: Express and Werkzeug disagree

We sent X-Forwarded-Proto: https, http and X-Forwarded-Host: a.example.com, b.example.com:

Framework and settingSchemeHost
Express 5.2.1, trust proxy true, 1, 2 or 'loopback'https (first value)a.example.com (first value)
Werkzeug 3.1.9, x_proto=1, x_host=1http (last value)b.example.com (last value)

Express applies the hop count only to X-Forwarded-For. For Proto and Host it checks that the direct peer is trusted and then takes the first, client-controlled value. So even with trust proxy 1, your proxy must overwrite X-Forwarded-Proto and X-Forwarded-Host, not append to them. See X-Forwarded-Proto in nginx and Host header injection.

6. What to configure

For a single nginx in front of Express or Flask (nginx config from the nginx docs, not measured here):

proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;
// Express
app.set('trust proxy', 1);

# Flask / Werkzeug
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

$proxy_add_x_forwarded_for appends to the client's value, which is fine when the app trusts exactly one hop. $scheme and $host replace whatever the client sent. More detail, including set_real_ip_from and real_ip_header: X-Forwarded-For and the real client IP in nginx.

If you also want the standard header, nginx has no built-in Forwarded variable; you build it with proxy_set_header Forwarded "for=$remote_addr;proto=$scheme;host=$host"; (from docs, not measured). That simple version breaks the RFC for IPv6 clients, which need for="[...]", and it drops earlier hops. Only send it if something downstream actually reads it, and keep the X-Forwarded-* headers alongside it for Express and Werkzeug.

7. Checklist

  1. Find out which header your framework reads. In our tests Express and Werkzeug read only X-Forwarded-*.
  2. Set the trust to the number of proxies you run, never true on a server the internet can reach directly.
  3. Make the proxy overwrite Proto and Host, and append (or overwrite) For.
  4. Test it: send X-Forwarded-For: 6.6.6.6 yourself and check that your logs still show your real IP.

Frequently asked questions

What is the difference between Forwarded and X-Forwarded-For?

Forwarded is the standard header from RFC 7239 and carries the client IP, scheme and host in one header (for=, proto=, host=, by=). X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host are older non-standard headers, one per value. Most frameworks still read only the X-Forwarded-* headers.

Does Express support the Forwarded header?

No. In Express 5.2.1, Forwarded: for=203.0.113.7;proto=https;host=shop.example.com left req.ip, req.protocol and req.hostname unchanged under every trust proxy setting we tried. Express reads only X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host.

Does Flask ProxyFix read the Forwarded header?

No. Werkzeug 3.1.9 ProxyFix with x_for=1, x_proto=1, x_host=1 ignored Forwarded completely and applied the same values when sent as X-Forwarded-* headers.

Is trust proxy true safe in Express?

Only if no client can reach the app except through your proxies. With trust proxy true, a client-sent X-Forwarded-For: 6.6.6.6 became req.ip after a proxy appended the real address. With trust proxy 1, req.ip was the real address.

How do I write an IPv6 address in the Forwarded header?

From RFC 7239: put it in brackets and double quotes, for example Forwarded: for="[2001:db8::1]:4711". IPv4 with a port also needs quotes: for="203.0.113.7:4711".

Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 and Express 5.2.1 (proxy-addr 2.0.8, forwarded 0.2.0) on localhost:8983, curl 8.14.1 as the client, and Werkzeug 3.1.9 ProxyFix on Python 3.13.5 (Werkzeug's in-process test client with REMOTE_ADDR 127.0.0.1, no socket). The RFC 7239 syntax and the nginx config are from the RFC and the nginx docs, not measured; nginx is not installed on the test box. PlainHeaders is our extension; Node.js, Express, Werkzeug and curl are free.