You deploy an Express app behind Nginx, a Docker ingress or a cloud load balancer, and every request in your logs comes from 127.0.0.1, ::ffff:172.18.0.2 or 10.0.3.17. Rate limiting treats all your users as one, and geolocation puts everyone in your data centre.
The usual fix you'll find is app.set('trust proxy', true). It makes the symptom go away, and it lets any client pick their own IP address. This guide shows the setting that is both correct and safe for each common setup. Every behaviour described here was checked against Express 5.3. Express 4 uses the same trust proxy rules.
Why req.ip is your proxy's address
By default Express doesn't trust any proxy. req.ip is req.socket.remoteAddress, the machine at the other end of the TCP connection. Behind a reverse proxy that machine is the proxy.
The proxy passes on the original address in the X-Forwarded-For header, and each hop appends the address it received from. Express ignores that header until you tell it which hops are yours. Why the header can't be taken at face value is covered in detail in X-Forwarded-For spoofing. The short version: the client can send the header too, so only the entries your own proxies appended can be trusted.
The trust proxy values
From the Express documentation:
| Value | Meaning | Safe? |
|---|---|---|
false (default) | No proxy. req.ip is the socket address | Yes, if there's no proxy |
true | Client is the leftmost X-Forwarded-For entry | No: the client controls that entry |
a number n | Trust n hops: the socket plus the n - 1 rightmost entries | Only if the app is unreachable except through exactly n proxies |
| IPs / CIDRs / names | Trust those addresses; the client is the first address from the right that isn't trusted | Yes, the robust choice |
a function (addr, i) => boolean | Custom trust decision per hop | Yes, if your function is |
The named presets are loopback (127.0.0.1/8, ::1/128), linklocal (169.254.0.0/16, fe80::/10) and uniquelocal (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7).
Here's what each setting gives for the same request: Nginx on the same host forwards a client at 198.51.100.9 that sent a forged X-Forwarded-For: 6.6.6.6. Nginx appends the real address, so the app receives X-Forwarded-For: 6.6.6.6, 198.51.100.9 from 127.0.0.1.
| trust proxy | req.ip |
|---|---|
| not set | 127.0.0.1 (the proxy) |
true | 6.6.6.6 (the forged value) |
1 | 198.51.100.9 |
'loopback' | 198.51.100.9 |
1 and 'loopback' look the same here. The difference shows when someone skips the proxy. We sent X-Forwarded-For: 1.2.3.4 straight to the app's port:
| trust proxy | req.ip |
|---|---|
1 | 1.2.3.4 (forged: a hop count trusts whoever connects) |
'loopback' | the caller's real address |
A hop count can't tell your proxy from an attacker. An address list can.
Pick the setting for your deployment
Nginx, Caddy or HAProxy on the same machine
import express from 'express';
const app = express();
app.set('trust proxy', 'loopback');
app.get('/debug-ip', (req, res) => res.json({ ip: req.ip, chain: req.ips }));
// Listen on localhost only, so the proxy is the only way in.
app.listen(3000, '127.0.0.1');And make sure the proxy appends the client address:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}$proxy_add_x_forwarded_for is the incoming header plus $remote_addr. Express then walks from the right, skips the loopback hop and stops at the client.
Docker or Kubernetes, proxy in another container
The proxy now connects from a container network address such as 172.18.0.5. Trust that network, not the whole world:
app.set('trust proxy', ['loopback', '172.18.0.0/16']); // your docker network's subnetdocker network inspect <name> shows the subnet. 'uniquelocal' also works, but it trusts every private range. That's fine only if no real clients can reach the app from a private address, which is often not the case on corporate networks or VPNs.
Cloud load balancer in a VPC
Load balancers connect from addresses inside your VPC or subnet. Trust that CIDR, plus loopback if there's also a local proxy:
app.set('trust proxy', ['loopback', '10.0.0.0/16']); // the VPC or LB subnetBehind a CDN such as Cloudflare
Trust the CDN's published ranges and close the origin to everything else. Real client IP behind Cloudflare has code that loads the ranges at startup.
A platform where you don't know the proxy addresses
Some PaaS routers connect from changing addresses. A hop count (app.set('trust proxy', 1)) is acceptable only if the platform documents that every request passes through its router, and the app can't be reached any other way. Read your platform's docs for how many hops it adds. Don't guess.
Never use true
true returns the leftmost address, which the client wrote. The express-rate-limit library even logs a dedicated error, ERR_ERL_PERMISSIVE_TRUST_PROXY, when it sees this setting, because it lets anyone bypass the limiter with a new header value per request. The Express docs add a caveat for true: the last trusted proxy has to remove or overwrite X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, or the client can provide any value. In practice that's hard to guarantee.
The opposite error is just as common. If X-Forwarded-For arrives but trust proxy is unset, express-rate-limit logs ERR_ERL_UNEXPECTED_X_FORWARDED_FOR, and every user shares the proxy's limit. See rate limiting by IP.
req.ip, req.ips and IPv6
req.ipis the single address you want.req.ipslists the forwarded addresses from the client onwards, left to right, without anything to the client's left. With['loopback', '10.0.0.0/8']and the header6.6.6.6, 198.51.100.9, 10.0.0.5,req.ipis198.51.100.9andreq.ipsis['198.51.100.9', '10.0.0.5']. Treat it as debugging information.- On a dual-stack socket, IPv4 clients can appear as
::ffff:203.0.113.7. Strip the prefix before storing or comparing:ip.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/, ''). trust proxyalso changesreq.protocolandreq.hostname, which then come fromX-Forwarded-ProtoandX-Forwarded-Host. The same trust list protects them.
Fastify
Fastify's option is trustProxy in the constructor. It takes true, a comma-separated string or an array of IPs and CIDRs, or a function:
import Fastify from 'fastify';
const app = Fastify({ trustProxy: ['127.0.0.1', '::1', '10.0.0.0/16'] });
app.get('/debug-ip', async (request) => ({ ip: request.ip, chain: request.ips }));
await app.listen({ port: 3000, host: '127.0.0.1' });Don't pass a number. Since Fastify 5.12.1, a plain hop count such as trustProxy: 1 is still accepted, but it trusts nothing: Fastify fails closed and ignores the forwarding headers, so request.ip stays the proxy's address. The Fastify docs explain why: a hop count "cannot validate the immediate peer and lets direct clients spoof X-Forwarded-* values". That's the same problem as Express's numeric setting. On older Fastify 5 releases a number still works as a hop count, with that weakness, so switch to a list either way.
Plain Node.js http, Koa and others
Without a framework setting, use a small function that walks X-Forwarded-For from the right and stops at the first untrusted address. The dependency-free version in X-Forwarded-For spoofing takes the socket address and the header:
import http from 'node:http';
import { clientIp } from './client-ip.js';
http.createServer((req, res) => {
const ip = clientIp(req.socket.remoteAddress, req.headers['x-forwarded-for']);
res.end(`your address: ${ip}\n`);
}).listen(3000, '127.0.0.1');Koa's app.proxy = true trusts the header from any peer, and app.maxIpsCount is a hop count. If you use Koa, keep the app on a private interface, or resolve the IP yourself with the function above.
Symptoms and what they mean
What you see in req.ip | Likely cause | Fix |
|---|---|---|
127.0.0.1 or ::1 for everyone | proxy on the same host, trust proxy unset | 'loopback' |
::ffff:172.18.0.5 or similar for everyone | proxy in another container | trust the Docker network's subnet |
10.x.x.x for everyone | cloud load balancer not trusted | trust the VPC or LB subnet |
| a CDN edge address (changes between requests) | CDN in front, not trusted | trust the CDN's published ranges |
a value that changes when you send X-Forwarded-For | trust proxy is true, or a hop count with the app reachable directly | use an address list and close the port |
| the right client IP for some requests, the proxy for others | two paths to the app (CDN and direct, or two load balancers) | list every proxy, not a hop count |
The last row is easy to miss. If some traffic goes straight to the load balancer while the rest goes through a CDN first, a hop count is right for one path and wrong for the other. An address list containing both the CDN ranges and the load balancer subnet handles both, because Express stops at the first untrusted address however many trusted hops there are.
Logging the right address
Request loggers read the IP in different ways. Check yours after changing trust proxy:
- morgan's
:remote-addrtoken usesreq.ip, so it followstrust proxy. - pino-http and winston middlewares log whatever their serializer reads. Some read
req.socket.remoteAddress, which is still the proxy. Set the field toreq.ipexplicitly. - Error trackers often capture the IP from headers themselves. Look at a real event and compare it with
req.ip.
When you're debugging, log both: req.ip for the client and req.socket.remoteAddress for the hop that actually connected. A mismatch you didn't expect means a proxy you didn't know about.
Check it in production
With the /debug-ip route from above deployed temporarily:
curl https://example.com/debug-ip # your public IP
curl -H "X-Forwarded-For: 1.2.3.4" https://example.com/debug-ip # still your public IP
curl -H "X-Forwarded-For: 1.2.3.4" http://ORIGIN_IP:3000/debug-ip # refused, or your IP, never 1.2.3.4Then delete the route.
Use the address: visitor country
Once req.ip is right, you can turn it into a location. This middleware looks the address up once, caches it, skips private ranges and never blocks a request for long:
import { BlockList, isIP } from 'node:net';
// Addresses with no public location: don't spend a lookup on them.
const nonPublic = new BlockList();
for (const [net, bits] of [['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16], ['127.0.0.0', 8], ['100.64.0.0', 10], ['169.254.0.0', 16]]) {
nonPublic.addSubnet(net, bits, 'ipv4');
}
for (const [net, bits] of [['::1', 128], ['fc00::', 7], ['fe80::', 10]]) nonPublic.addSubnet(net, bits, 'ipv6');
const cache = new Map(); // ip -> { country, expires }
async function countryFor(ip) {
const family = isIP(ip);
if (!family || nonPublic.check(ip, family === 6 ? 'ipv6' : 'ipv4')) return null;
const hit = cache.get(ip);
if (hit && hit.expires > Date.now()) return hit.country;
let country = null;
try {
const res = await fetch(
`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1500) }
);
if (res.ok) {
const data = await res.json();
if (data.country_code && data.country_code !== 'No Data') country = data.country_code;
}
} catch {
// timeout or network error: carry on without a country
}
if (cache.size > 10_000) cache.delete(cache.keys().next().value);
cache.set(ip, { country, expires: Date.now() + (country ? 6 * 3600_000 : 600_000) });
return country;
}
export async function visitorCountry(req, res, next) {
req.country = await countryFor(req.ip); // req.ip is only right if trust proxy is right
next();
}Any field the data doesn't cover is the string "No Data", never null, so the check above matters. For the full Express setup (key handling, error codes, the other fields), see the Express integration guide. For other languages, see get a visitor's country from their IP.
FAQ
Why does req.ip start with ::ffff:?
Node listens on a dual-stack socket by default, and an IPv4 client is represented as an IPv4-mapped IPv6 address. It's the same client. Strip the prefix as shown above.
Is req.headers['x-forwarded-for'] ever the right thing to read directly?
Only to log the raw chain for debugging. For anything that grants or limits access, use req.ip with a correct trust proxy, or the right-to-left function.
Does trust proxy affect req.secure and redirects to HTTPS?
Yes. req.protocol and req.secure come from X-Forwarded-Proto once the proxy is trusted. That's what you want behind a TLS-terminating proxy, and another reason not to trust everyone.