How to Get a Visitor’s IP and Country in Express (Behind Proxies and CDNs)
Fix req.ip behind Nginx, load balancers and CDNs, skip the spoofable X-Forwarded-For pattern, and add a cached, fail-open country lookup with an honest accuracy radius.
Free: 1,000 req/day · HTTPS · commercial OK
Why req.ip shows your proxy’s IP
In production your Express app almost never talks to the visitor directly. Nginx, a cloud load balancer or a CDN accepts the connection and opens a new one to Node. From Express’s point of view, the “client” is that proxy, so req.ip returns something like 10.0.0.12 or 127.0.0.1.
The real address travels in the X-Forwarded-For header, which each proxy appends to. Express can read it for you, but only if you tell it which proxies to trust.
Configure trust proxy correctly
Set trust proxy to match your real topology. With one proxy in front of Node, 1 means “trust exactly one hop”:
const express = require("express");
const app = express();
// One reverse proxy (Nginx, a load balancer) in front of Node:
app.set("trust proxy", 1);
// Or trust only specific proxy addresses / subnets:
// app.set("trust proxy", ["loopback", "10.0.0.0/8"]);
app.get("/whoami", (req, res) => {
res.json({ ip: req.ip, chain: req.ips });
});
- Nginx / one load balancer:
app.set("trust proxy", 1), and make sure the proxy setsX-Forwarded-For(in Nginx:proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;). - CDN + load balancer: count the hops (for example
2), or list the proxy subnets explicitly. - Cloudflare in front: Cloudflare sends the visitor’s address in
CF-Connecting-IP. Only rely on it if your origin accepts traffic from Cloudflare alone; otherwise anyone can hit the origin and set the header.
Avoid app.set("trust proxy", true) unless every request really passes through proxies you control: it makes Express take the leftmost address in the header, which is client-controlled.
Never trust raw X-Forwarded-For
Plenty of snippets read the header directly. That’s spoofable in one line:
// DON'T: anyone can send this header
const ip = req.headers["x-forwarded-for"].split(",")[0];
// $ curl -H "X-Forwarded-For: 8.8.8.8" https://your-app.example/
// -> your app now "thinks" the visitor is in Mountain View
With trust proxy set to a hop count or subnet list, Express walks the chain from the right and stops at the first untrusted address, which is the one your own proxy actually saw. Use req.ip, not the raw header.
Look up the country with a middleware
Once req.ip is right, one call to GET /v0/ip/{ip} returns location, network and traits. This middleware adds a cache, an 800 ms timeout and fails open, so a slow lookup never takes your site down:
// geo.js (Node 18+, global fetch)
const API = "https://api.networkdatalabs.com/v0/ip/";
const KEY = process.env.NDL_API_KEY;
const cache = new Map(); // swap for Redis/LRU in production
const TTL_MS = 6 * 60 * 60 * 1000;
function normalize(ip) {
return ip && ip.startsWith("::ffff:") ? ip.slice(7) : ip;
}
async function lookup(ip) {
const hit = cache.get(ip);
if (hit && hit.exp > Date.now()) return hit.data;
const res = await fetch(API + encodeURIComponent(ip), {
headers: { Authorization: `Bearer ${KEY}` },
signal: AbortSignal.timeout(800), // latency budget
});
if (!res.ok) throw new Error(`lookup ${res.status}`);
const data = await res.json();
cache.set(ip, { data, exp: Date.now() + TTL_MS });
return data;
}
function geo() {
return async (req, res, next) => {
const ip = normalize(req.ip);
try {
const data = await lookup(ip);
req.geo = {
ip,
country: data.location?.country_code ?? null,
region: data.location?.region ?? null,
city: data.location?.city ?? null,
radiusKm: data.location?.accuracy_radius_km ?? null,
confidence: data.location?.confidence ?? "unknown",
traits: data.traits ?? {},
};
} catch (err) {
req.geo = null; // fail open: never block the request
}
next();
};
}
module.exports = { geo };
// app.js
const { geo } = require("./geo");
app.use(geo());
app.get("/", (req, res) => {
const g = req.geo;
res.json({
ip: g?.ip,
country: g?.country, // "US"
city: g?.city, // "Mountain View"
radius_km: g?.radiusKm,
confidence: g?.confidence,
});
});
Keep the API key on the server (NDL_API_KEY environment variable) and send it as Authorization: Bearer. Don’t call the API from the browser: you would expose the key, and ad blockers often block third-party lookups anyway.
Check accuracy_radius_km and confidence before acting
Country is reliable enough for defaults like currency or language. City is an estimate. Every response says how much to trust it: accuracy_radius_km is the estimated radius in kilometers, and confidence is high, medium, low or unknown.
function usableCity(g) {
if (!g) return null;
const precise = g.confidence === "high" || g.confidence === "medium";
return precise && g.radiusKm !== null && g.radiusKm <= 100 ? g.city : null;
}
// Country for defaults (currency, language); city only when it's solid
const country = req.geo?.country ?? "US";
const city = usableCity(req.geo);
Example: 8.8.8.8
$ curl -s https://api.networkdatalabs.com/v0/ip/8.8.8.8 \
-H "Authorization: Bearer YOUR_API_KEY"
{
"ip": "8.8.8.8",
"location": {
"country_code": "US",
"region": "California",
"city": "Mountain View",
"accuracy_radius_km": 50.0,
"confidence": "medium",
...
},
"network": { "asn": 15169, "as_org": "Google LLC", "network": "8.8.8.0/24", ... },
"traits": { "is_anycast": true, "is_hosting": true, "is_vpn": false, "is_tor": false, ... }
}
8.8.8.8 is an anycast public DNS resolver, so it’s answered from many places at once. The response flags it with is_anycast: true and is_hosting: true, and places it in Mountain View with a 50 km radius and medium confidence, so usableCity() above would accept the city. When the radius is wide or the confidence is low, use the country for defaults and skip the city.
Caching, timeouts and quota
- Cache per IP for a few hours. Most of your traffic is repeat visitors, so the cache absorbs most lookups.
- Skip what you don’t need: only look up on pages that use location (checkout, pricing, signup), not on every static asset.
- Fail open: on timeout or error, fall back to a default instead of returning a 500.
- Backfilling logs? Use
POST /v0/bulkwith up to 100 IPs per request instead of a loop of single lookups.
FAQ
Why do I get ::ffff:203.0.113.5?
That’s an IPv4 address mapped into IPv6 by Node’s dual-stack socket. Strip the ::ffff: prefix, as the normalize() helper does. Real IPv6 addresses work as-is.
What do I get in local development?
127.0.0.1 or ::1, which are private addresses with no location. The API returns them with is_bogon: true. For testing, hard-code a public IP such as 8.8.8.8.
Why not look up the IP in the browser?
You’d ship your API key to every visitor, ad blockers cut the request, and the client can fake the result. Do it on the server where you already have the right IP.
Can I detect VPN and Tor users with the same call?
Yes. The same response includes traits.is_tor (current Tor exit nodes), is_vpn (known VPN networks, including major commercial VPN providers), is_relay (privacy relay) and is_hosting (hosting/datacenter). Remember that behind those, the location is the exit’s, not the user’s.
Related
- IP to Country API: country code, name, continent and
is_eu - Flag risky signups in Python by IP
- Cloudflare Workers: ASN, hosting and Tor checks beyond request.cf
- API Docs: quickstart, endpoints and the full field reference