Client IP spoofing via trust proxy
Most threat pages describe a vulnerability. This one describes a setting, because in
Express the interesting failure is not a bug in a dependency — it is that a single
configuration value silently decides whether req.ip is something you observed or
something the client asserted.
Why it is one header
Section titled “Why it is one header”X-Forwarded-For is a list. Each proxy appends the address it saw, so a request that
passed through your load balancer arrives looking like:
X-Forwarded-For: <client>, <your-proxy-saw-this>A client can send the header too. If they do, their value is already in the list before your proxy appends anything — sitting on the left.
app.set('trust proxy', true) tells Express to trust the whole chain, and Express
takes the leftmost entry. That is the attacker’s value. req.ip now returns whatever
they typed.
X-Forwarded-For: 203.0.113.9 → req.ip === "203.0.113.9"No exploit, no CVE, no dependency to patch. Documented behaviour, wrong setting.
curl -sI -H 'X-Forwarded-For: 203.0.113.9' https://example.com/Check what your application logged for that request. If it recorded 203.0.113.9,
every control below is currently defeated.
The Express 'trust proxy' setting is true, which allows anyone to trivially bypass IP-based rate limiting.A maintained library refusing to operate on this configuration, in its own words. It is the clearest statement of this threat that exists in the ecosystem.
Read in: express-rate-limit source/validations.ts — ERR_ERL_PERMISSIVE_TRUST_PROXYWhat breaks at once
Section titled “What breaks at once”The reason this is rated high rather than medium is blast radius. Everything that
consumes req.ip inherits the mistake simultaneously:
Rate limiting. A limiter keyed on a spoofable value is not a limiter — rotate the header per request and every bucket is fresh. This makes credential stuffing against login endpoints unlimited while the dashboard shows the limiter working.
IP allow-lists. Any “admin only from the office range” check becomes a request the attacker can construct. This is the one that turns a misconfiguration into unauthorised access.
Audit logs and abuse heuristics. Now attacker-controlled. Worse than useless during an incident, because they read as authoritative — you will chase an address the attacker chose.
Geo and fraud rules, for the same reason.
The other direction fails too
Section titled “The other direction fails too”Leaving trust proxy at its default false behind a proxy is not safe, just broken
differently: req.ip becomes the proxy’s address for everyone, so all visitors share
one rate-limit bucket and secure cookies never send. express-rate-limit warns about
this case separately (ERR_ERL_UNEXPECTED_X_FORWARDED_FOR).
There is no safe default here. Both ends of the setting are wrong for a proxied
deployment, which is why it needs a deliberate value:
app.set('trust proxy', 1) — the hop count you actually run. That is on the
trust proxy control page.
The application cannot fix this alone
Section titled “The application cannot fix this alone”Express trusting the last hop is only correct if that hop is honest. If your proxy
forwards a client-supplied X-Forwarded-For untouched, the hop count is arithmetic on
attacker-supplied data.
The durable fix is at both layers: the proxy overwrites or appends the header itself,
and the application trusts exactly the number of hops it owns. Remember that proxies
append to X-Forwarded-For rather than replace it — which is precisely why an
application reading the first value is reading whatever the client sent.
Related
Section titled “Related”- trust proxy — the control that closes this
- Application vs server — why this one spans both
- Django has the same seam in a different setting — SECURE_PROXY_SSL_HEADER is only as trustworthy as the proxy’s stripping rule.