Skip to content

Client IP spoofing via trust proxy

Severity: highApplies to: Express 4.xApplies to: Express 5.xFacts last verified 2026-07-26 against Express 5.2.1 · express-rate-limit 8.6.0

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.

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.

verify it workedRun this in: http response
Terminal window
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.

if you see this
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_PROXY

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.

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.

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.