Skip to content

Express behind Cloudflare — trust proxy and CF-Connecting-IP

Severity: highApplies to: Express 4.xApplies to: Express 5.2.1Facts last verified 2026-08-16 against Express 5.2.1 · Node 25.9

trust proxy is a hop count, and behind Cloudflare the number of hops is not obvious. This page is about getting that count right, and about the second header everyone reaches for once they have.

Three things matter, all from Cloudflare’s own header reference:

  • X-Forwarded-For — if the request arrived with none, Cloudflare sets it to the same value as CF-Connecting-IP. If proxies already exist in the chain, Cloudflare appends to the list rather than replacing it.
  • X-Forwarded-Proto — set to the protocol the client used, and if the client set a different value it is overwritten. You cannot forge this one through Cloudflare.
  • CF-Connecting-IP — the client address as Cloudflare saw it.

The appending behaviour is the one that decides your hop count, because it means a client can put an entry to the left of Cloudflare’s.

Every value looks right until someone forges

Section titled “Every value looks right until someone forges”

Measured on Express 5.2.1 under Node 25.9. First, an ordinary request through Cloudflare, where the client sent no X-Forwarded-For of its own:

trust proxy req.ip
unset (false) ::ffff:127.0.0.1 — the connecting socket
true 203.0.113.7
1 203.0.113.7
2 203.0.113.7

Three different values, one correct answer. Nothing here tells you which is right.

Now the same application, same settings, with a client that sent X-Forwarded-For: 1.2.3.4 before Cloudflare appended its own observation — so the header arrives as 1.2.3.4, 203.0.113.7:

trust proxy req.ip
unset (false) ::ffff:127.0.0.1 proxy, not client
true 1.2.3.4 the forged value
1 203.0.113.7 correct
2 1.2.3.4 the forged value

A hop count of 2 is exactly as exploitable as true here. It is also the value people arrive at by reasoning “Cloudflare, then my load balancer, so two” — which is right only if a second proxy of your own really does append a third entry. If Cloudflare talks straight to your application, the count is 1, and 2 walks one entry further left than Cloudflare wrote, into the part the client controls.

The important part is the pair of tables together: the misconfiguration is invisible in normal traffic. You cannot reach the right value by testing with a browser, because every wrong value returns the right answer until someone deliberately sends a header.

the fix
// Cloudflare is the only proxy in front of this app.
app.set("trust proxy", 1);

Count the hops that append to X-Forwarded-For, not the boxes the traffic passes through. A TLS terminator or a container network that forwards without touching the header does not add to the count.

settings on this page
trust proxyoption

Behind Cloudflare with no other proxy the answer is 1. Cloudflare appends to X-Forwarded-For rather than replacing it, so a count of 2 walks one entry further left than Cloudflare wrote — into the part the client controls. Reproduced: with a forged header, 2 and true both return the forged address.

accepts
1 | 2 | true | <ip or subnet>
default
false
set in
app.set()
Read in: reproduced — Express 5.2.1 on Node 25.9, X-Forwarded-For '1.2.3.4, 203.0.113.7'
CF-Connecting-IPheader

The client IP as seen by Cloudflare. It is only evidence if the request actually arrived through Cloudflare — an origin reachable directly will accept this header from anyone. Cloudflare's documentation describes what it adds; it does not state that a client-supplied value is overwritten, so treat the header's authority as coming from the network path, not the header.

set in
req.headers
Read in: Cloudflare Fundamentals — HTTP request headers reference
verify it workedRun this in: http response
Terminal window
# send an address that is not yours and see whether the app believes it
curl -s https://example.com/whoami -H 'X-Forwarded-For: 1.2.3.4'

Expose req.ip on a temporary route, or read it out of your access log. If 1.2.3.4 appears anywhere, the count is too high. Run the same request without the header to confirm the route works at all — a normal request proves nothing on its own, which is the whole point of the tables above.

CF-Connecting-IP is only as good as the path

Section titled “CF-Connecting-IP is only as good as the path”

Having found that req.ip is awkward, the usual next move is to skip it and read req.headers['cf-connecting-ip'] instead. That is a plain string that arrived in a request, and Express does nothing to it.

Cloudflare’s documentation describes what Cloudflare adds to a request. It does not state that a client-supplied CF-Connecting-IP is overwritten — and for the related True-Client-IP header it warns explicitly that in a stacked-CDN setup its value “can be spoofed to any value” unless you set the header yourself.

The decisive question is not whether the header can be forged through Cloudflare. It is whether anyone can reach your origin without going through Cloudflare at all. If your origin answers requests from the open internet, an attacker skips the proxy, writes whatever CF-Connecting-IP they like, and every control you built on it reads their fiction. No Express setting can detect this, because from the application’s side the request looks exactly like a real one.

That makes origin reachability part of this control rather than a separate concern. Cloudflare rates its own options: Cloudflare Tunnel and Authenticated Origin Pulls as “very secure”, and allowing only Cloudflare’s IP ranges at your firewall as “moderately secure”, since IP allowlisting is itself vulnerable to spoofing. Its guidance is that no single method is sufficient.

Until one of those is in place, CF-Connecting-IP and a correct trust proxy are both claims rather than facts. The setting is the application’s half of the control; the network path is the other half, and this page cannot close it for you.

before you ship this

Lowering trust proxy from true or 2 to 1 changes what every IP-based control sees at once. Rate-limit buckets re-key, so counters reset and a user mid-limit gets a clean slate. IP allow-lists start matching real client addresses instead of forged or proxy ones — which is the point, and which will lock out anyone whose entry was added while the wrong value was in place. Audit logs change meaning at the deploy boundary, so keep the change in your log so later readers know why addresses shifted.

If you genuinely run a second proxy behind Cloudflare, the count is 2 and the table above does not apply to you — confirm it with the forged-header check rather than by counting infrastructure, since only the appending hops matter.

Raising the count is the dangerous direction and it fails silently, so treat any increase as a security change rather than a configuration tweak.