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
| Information | X-Forwarded-* header | Forwarded parameter |
|---|---|---|
| Original client IP | X-Forwarded-For | for= |
| Original scheme (http/https) | X-Forwarded-Proto | proto= |
| Original Host header | X-Forwarded-Host | host= |
| The proxy's own interface | (none) | by= |
Rules from RFC 7239 (not measured):
- Parameters inside one element are separated by
;. Each proxy adds an element; elements are separated by,, so a two-proxy chain looks likeForwarded: for=203.0.113.7;proto=https, for=198.51.100.2. - An IPv6 address must be in brackets and quotes, because
:and[are not allowed in an unquoted value:for="[2001:db8::1]:4711". An IPv4 address with a port needs quotes too:for="203.0.113.7:4711". - A proxy that does not want to reveal an address can send
for=unknownor an obfuscated identifier starting with_, for examplefor=_hidden. - The main advantage is that one header carries all the values per hop, so the IP and the scheme of the same hop stay together. With
X-Forwarded-*they are three separate lists that may not line up.
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 headers | req.ip | req.protocol | req.hostname |
|---|---|---|---|
| (none) | 127.0.0.1 | http | localhost |
X-Forwarded-For: 203.0.113.7, X-Forwarded-Proto: https, X-Forwarded-Host: shop.example.com | 203.0.113.7 | https | shop.example.com |
Forwarded: for=203.0.113.7;proto=https;host=shop.example.com | 127.0.0.1 | http | localhost |
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 headers | remote_addr | scheme | host |
|---|---|---|---|
X-Forwarded-For: 203.0.113.7 + Proto https + Host shop.example.com | 203.0.113.7 | https | shop.example.com |
Forwarded: for=203.0.113.7;proto=https;host=shop.example.com | 127.0.0.1 | http | localhost |
The string Forwarded (as a header name) does not appear anywhere in the installed Werkzeug package. Two more things we measured:
ProxyFix(app)with no arguments defaults tox_for=1, x_proto=1, so it already appliedX-Forwarded-Proto: https.X-Forwarded-Hostis off until you setx_host=1.- Without ProxyFix,
request.remote_addrstayed127.0.0.1, butrequest.access_routereturned the raw client-sent list, for example["1.2.3.4", "203.0.113.7", "198.51.100.2"]. Do not useaccess_route[0]as the client IP.
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 proxy | req.ip | req.ips |
|---|---|---|
false (default) | 127.0.0.1 | [] |
true | 6.6.6.6 (the client's fake) | ["6.6.6.6", "198.51.100.2"] |
1 | 198.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 setting | Scheme | Host |
|---|---|---|
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=1 | http (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
- Find out which header your framework reads. In our tests Express and Werkzeug read only
X-Forwarded-*. - Set the trust to the number of proxies you run, never
trueon a server the internet can reach directly. - Make the proxy overwrite Proto and Host, and append (or overwrite) For.
- Test it: send
X-Forwarded-For: 6.6.6.6yourself 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.