If you are running an Express.js application behind a reverse proxy setup like Cloudflare → Nginx → Express, accurately identifying a visitor’s real IP address is critical. Whether you are enforcing rate limits, analyzing web traffic, or logging security events, relying on low-level socket properties like req.socket.remoteAddress will leave you seeing only Cloudflare’s or Nginx’s IP address.
Worse, misconfiguring proxy trust settings in Express can expose your application to IP spoofing attacks, allowing malicious actors to bypass rate limiters with forged headers.
Here is a breakdown of how the header chain works, why traditional setups fail, and how to safely configure Express with app.set('trust proxy', 2).
The Problem: Reverse Proxies and Header Chains
When a user visits your web application through Cloudflare and Nginx, the request travels through two distinct proxy hops before hitting your Node.js application:
- Client connects to Cloudflare (
162.159.x.x) - Cloudflare connects to your Nginx server (
127.0.0.1) - Nginx forwards the request to Express.js
If you query req.socket.remoteAddress inside Express, Node.js will report 127.0.0.1 (or Nginx’s local socket IP). If you do not evaluate proxy headers, your rate limiter will treat every user on the internet as a single visitor, quickly locking out legitimate traffic.
To pass the real client IP down the chain, proxies append client IP addresses to the X-Forwarded-For HTTP header.
Understanding the X-Forwarded-For Header
As a request moves through each proxy, the X-Forwarded-For header gets appended from left to right:
X-Forwarded-For: Client IP, Cloudflare IP
When Nginx receives the request from Cloudflare and passes it to Express via proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, the full header reaching Express looks like this:
X-Forwarded-For: 203.0.113.195, 162.159.116.147
203.0.113.195is the real client IP attached by Cloudflare.162.159.116.147is the Cloudflare edge proxy IP attached by Nginx.
The Vulnerability: Why app.set('trust proxy', true) Is Dangerous
A common solution in Express tutorials is to enable full proxy trust:
// DO NOT DO THIS IN PRODUCTION
app.set('trust proxy', true);
Setting trust proxy to true tells Express to trust every IP listed in the X-Forwarded-For chain without restriction, returning the leftmost IP as req.ip.
How Attackers Exploit This
If an attacker sends a request directly to your origin or through Cloudflare with a manually forged header:
X-Forwarded-For: 1.1.1.1, 2.2.2.2
- Cloudflare appends the attacker’s actual IP (
198.51.100.5):X-Forwarded-For: 1.1.1.1, 2.2.2.2, 198.51.100.5 - Nginx appends Cloudflare’s IP (
162.159.116.147):X-Forwarded-For: 1.1.1.1, 2.2.2.2, 198.51.100.5, 162.159.116.147 - With
trust proxy: true, Express steps all the way back to the leftmost IP and resolvesreq.ipas1.1.1.1.
By changing the fake IP in their request header on every hit, an attacker can trivially bypass custom rate limiters and IP-based blocking logic.
The Solution: Explicit Hop Counting with trust proxy: 2
Instead of trusting the entire header blindly, you can tell Express exactly how many proxy hops sit between it and the untrusted public internet.
In a Cloudflare → Nginx → Express stack, there are exactly 2 trusted hops:
- Hop 1 (Outer): Cloudflare
- Hop 2 (Inner): Nginx
To configure Express safely, explicitly set:
// Trust exactly 2 proxy hops back from Express
app.set('trust proxy', 2);
How Express Resolves req.ip with Hop Counting
When trust proxy is set to 2, Express evaluates the X-Forwarded-For chain starting from the right (the closest proxy) and steps back exactly 2 hops:
X-Forwarded-For: ["1.1.1.1", "2.2.2.2", "198.51.100.5", "162.159.116.147"]
▲ ▲
│ └─ Hop 1 (Nginx)
└─ Hop 2 (Cloudflare) -> Express selects THIS as req.ip
Express ignores 1.1.1.1 and 2.2.2.2 entirely because they lie outside the trusted 2-hop boundary. req.ip correctly resolves to 198.51.100.5 (the real client IP validated by Cloudflare).
Verifying Your Setup
You can verify how Express processes incoming IP chains by adding a temporary debugging middleware:
app.use((req, res, next) => {
const rawXff = req.headers['x-forwarded-for'] || '';
const xffArray = rawXff.split(',').map(ip => ip.trim());
console.log('--- IP Resolution Debug ---');
console.log(`Raw X-Forwarded-For Header : "${rawXff}"`);
console.log(`Parsed IP Array :`, xffArray);
console.log(`Express Selected req.ip : ${req.ip}`);
console.log(`Direct Socket Remote IP : ${req.socket.remoteAddress}`);
console.log('---------------------------');
next();
});
Key Rules to Remember
- Always read
req.ipin custom rate limiters or security tools—never readreq.socket.remoteAddressdirectly when running behind a proxy. - Avoid
app.set('trust proxy', true)in multi-tier architecture to prevent header injection vulnerabilities. - Match your
trust proxyinteger setting directly to the number of reverse proxy layers in your infrastructure (e.g.,1for Nginx only,2for Cloudflare + Nginx).
As I was testing this setup I found that even though Express was logging the real client IP, NGINX was not. It turns out you also have to add following to the http block in /etc/nginx/nginx.conf for NGINX itself to log the real client IP in its logs:
http {
# https://gemini.google.com/app/2722d33b9f3f1f27
# Trust Cloudflare IPv4 ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
# Trust Cloudflare IPv6 ranges
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
# Tell Nginx which header contains the real visitor IP
real_ip_header CF-Connecting-IP;
}
Add this to /etc/nginx/nginx.conf so its available to all other configs without having to add to each one of them.

