Get the client IP address in Node.js and Express behind a proxy

Express gives you req.ip, but behind Nginx or a load balancer it's the proxy's address until you set trust proxy. Set it too loosely and any client can choose their own IP. Here is the setting for each common deployment, tested on Express 5.

On this page Why req.ip is your proxy's address

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:

ValueMeaningSafe?
false (default)No proxy. req.ip is the socket addressYes, if there's no proxy
trueClient is the leftmost X-Forwarded-For entryNo: the client controls that entry
a number nTrust n hops: the socket plus the n - 1 rightmost entriesOnly if the app is unreachable except through exactly n proxies
IPs / CIDRs / namesTrust those addresses; the client is the first address from the right that isn't trustedYes, the robust choice
a function (addr, i) => booleanCustom trust decision per hopYes, 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 proxyreq.ip
not set127.0.0.1 (the proxy)
true6.6.6.6 (the forged value)
1198.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 proxyreq.ip
11.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

js
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:

nginx
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:

js
app.set('trust proxy', ['loopback', '172.18.0.0/16']); // your docker network's subnet

docker 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:

js
app.set('trust proxy', ['loopback', '10.0.0.0/16']); // the VPC or LB subnet

Behind 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.ip is the single address you want.
  • req.ips lists 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 header 6.6.6.6, 198.51.100.9, 10.0.0.5, req.ip is 198.51.100.9 and req.ips is ['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 proxy also changes req.protocol and req.hostname, which then come from X-Forwarded-Proto and X-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:

js
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:

js
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.ipLikely causeFix
127.0.0.1 or ::1 for everyoneproxy on the same host, trust proxy unset'loopback'
::ffff:172.18.0.5 or similar for everyoneproxy in another containertrust the Docker network's subnet
10.x.x.x for everyonecloud load balancer not trustedtrust the VPC or LB subnet
a CDN edge address (changes between requests)CDN in front, not trustedtrust the CDN's published ranges
a value that changes when you send X-Forwarded-Fortrust proxy is true, or a hop count with the app reachable directlyuse an address list and close the port
the right client IP for some requests, the proxy for otherstwo 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-addr token uses req.ip, so it follows trust proxy.
  • pino-http and winston middlewares log whatever their serializer reads. Some read req.socket.remoteAddress, which is still the proxy. Set the field to req.ip explicitly.
  • 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:

bash
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.4

Then 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:

js
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.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.