Express trust proxy: Reading the Real Client IP Behind Nginx and CDNs
When Express sits behind Nginx or a CDN, req.ip is wrong by default. Learn how trust proxy works, configure trusted hops safely, and verify client-IP behavior without exposing your app to header spoofing.
03 Oct 2026, 06:54 UTC

The Wrong-IP Problem Behind a Reverse Proxy
Your Express app is healthy, your Nginx config is clean, and your rate limiter is active. Then you check the logs and every request appears to come from 127.0.0.1 or a single load-balancer address. The same thing happens to geo lookups, allowlists, and audit trails: they all point at the proxy instead of the user. The cause is usually not a bug in your middleware. It is that Express, by default, trusts only the TCP socket peer, so req.ip resolves to the proxy that terminated the connection.
The useful takeaway: explicitly declare which proxy hops Express should believe. Do not leave trust proxy at its default for public services, and do not set it to true in production. Pin the smallest scope that matches your real topology.
What trust proxy Actually Controls
Express exposes this through app.set('trust proxy', value). The setting determines how req.ip and req.ips are derived from the X-Forwarded-For header. Accepted values include false, true, a hop count, a list of trusted IP addresses or subnets, or a custom function.
When a request arrives, Express looks at the socket peer (req.socket.remoteAddress) and walks the forwarded chain outward. It skips the number of trusted hops you declared and treats the first remaining address as the client. req.ips exposes the parsed chain so you can see what the proxies reported. The header itself is not authenticated; trust proxy only tells Express which positions in that chain to believe.
Worked Example: One Nginx Hop
Suppose Nginx runs on the same host as your Node process and forwards traffic to 127.0.0.1:3000. From Express's perspective, every connection comes from the loopback interface. To recover the real client address, trust exactly one hop.
Express configuration
const express = require('express');
const app = express();
// Trust exactly one proxy hop (e.g., Nginx on the same host)
app.set('trust proxy', 1);
app.get('/whoami', (req, res) => {
res.json({
remote: req.socket.remoteAddress,
ip: req.ip,
ips: req.ips
});
});
Run your application with normal privileges on the app server. No special OS permissions are required.
Nginx configuration
On the Nginx host, ensure the upstream block forwards the original client chain. A minimal location block looks like this:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
Reload Nginx after changing the config (for example, sudo nginx -s reload). If a CDN or external load balancer sits in front of Nginx, the hop count is no longer 1. Either count every trusted hop or list the CDN and Nginx subnets explicitly.
Comparing the behavior
| Setting | req.ip source | Practical risk |
|---|---|---|
false (default) | Socket peer | Proxy address logged as client |
1 | First address after one trusted hop | Correct for a single known proxy; brittle if topology changes |
true | Left-most forwarded address | Any client can forge the header and become the "client" |
Verification: Don't Trust It Until You See It
Configuration that looks right on paper can still be wrong if a proxy strips headers, uses a Unix socket, or adds an unexpected hop. Verify through the real path.
- Deploy the temporary
/whoamiroute above. - From a client machine, call the service through the proxy:
curl -s http://your-proxy-host/whoami. Replaceyour-proxy-hostwith the actual hostname or IP clients use. - Call Express directly, bypassing the proxy:
curl -s http://127.0.0.1:3000/whoami. This shows the difference between the socket peer and the forwarded chain. - Send a forged header directly to the app:
curl -s -H "X-Forwarded-For: 203.0.113.9" http://127.0.0.1:3000/whoami. Withtrust proxyset to1,req.ipshould not become203.0.113.9because the socket peer is not a trusted proxy. - Compare the three values (
remote,ip,ips) acrossfalse, your intended setting, andtrue. The intended setting should return the real client IP and reject the forged direct header.
These examples assume Express 4.x or 5.x; check your installed major version's documentation for the exact accepted values and defaults.
Trade-offs and Limitations
trust proxy does not authenticate the client. It only tells Express which hops to believe. If an untrusted hop can write X-Forwarded-For, req.ip is spoofable. A hop count is also brittle: adding a CDN, removing a proxy, or running multiple proxies on one host can silently shift which address is treated as the client without any code change. Finally, req.ip is not an identity or authorization signal. Use it for telemetry, coarse rate limiting, or debugging, and validate tokens or sessions separately.
Actionable Close
Before shipping, log req.ip, req.ips, and req.socket.remoteAddress for a few requests through the real proxy path. Pin the smallest trust scope that matches production—prefer explicit subnets over true, and prefer explicit subnets over a bare hop count when the topology is complex. Add a regression test that asserts req.ip for a known X-Forwarded-For fixture, and watch for IPv4-mapped IPv6 addresses or unexpectedly empty chains. If you need to revert, remove the app.set('trust proxy', ...) line or set it back to false, then redeploy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.