Cómo obtener la IP y el país del visitante en Express (detrás de proxies y CDN)
Arreglá req.ip detrás de Nginx, balanceadores y CDN, evitá el patrón falsificable de X-Forwarded-For y sumá una consulta de país con caché, fail-open y radio de precisión honesto.
Gratis: 1.000 req/día · HTTPS · uso comercial OK
Por qué req.ip te devuelve la IP del proxy
En producción tu app de Express casi nunca habla directo con el visitante. Nginx, un balanceador o una CDN recibe la conexión y abre otra hacia Node. Para Express, el “cliente” es ese proxy, así que req.ip te da algo como 10.0.0.12 o 127.0.0.1.
La IP real viaja en el header X-Forwarded-For, al que cada proxy le suma una dirección. Express lo puede leer por vos, pero solo si le decís en qué proxies confiar.
Configurá bien trust proxy
El valor de trust proxy tiene que reflejar tu infraestructura real. Con un solo proxy delante de Node, 1 significa “confiá en un salto, ni uno más”:
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 o un balanceador:
app.set("trust proxy", 1), y revisá que el proxy mandeX-Forwarded-For(en Nginx:proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;). - CDN + balanceador: contá los saltos (por ejemplo
2) o listá las subredes de tus proxies. - Cloudflare adelante: Cloudflare manda la IP del visitante en
CF-Connecting-IP. Usalo solo si tu origen acepta tráfico únicamente desde Cloudflare; si no, cualquiera le pega directo al origen y pone el header que quiera.
Evitá app.set("trust proxy", true) salvo que todo el tráfico pase sí o sí por proxies tuyos: con eso Express toma la primera dirección del header, que la controla el cliente.
Nunca leas X-Forwarded-For a mano
Muchos ejemplos leen el header directamente. Falsificarlo lleva una línea:
// 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
Con trust proxy configurado con cantidad de saltos o subredes, Express recorre la cadena desde la derecha y se queda con la primera dirección no confiable, que es la que vio tu propio proxy. Usá req.ip, no el header crudo.
Obtené el país con un middleware
Con req.ip bien resuelto, una llamada a GET /v0/ip/{ip} te devuelve ubicación, red y traits. Este middleware suma caché, timeout de 800 ms y fail-open: si la consulta tarda, tu sitio sigue andando.
// 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,
});
});
La API key queda en el servidor (variable de entorno NDL_API_KEY) y viaja como Authorization: Bearer. No consultes la API desde el navegador: expondrías la key y los bloqueadores de anuncios suelen cortar ese tipo de pedidos.
Mirá accuracy_radius_km y confidence antes de decidir
El país alcanza para defaults como moneda o idioma. La ciudad es una estimación, y cada respuesta te dice cuánto confiar en ella: accuracy_radius_km es el radio estimado en kilómetros y confidence puede ser high, medium, low o 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);
Ejemplo: 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 es un resolver DNS público anycast: responde desde muchos lugares a la vez. La respuesta lo marca con is_anycast: true e is_hosting: true, y lo ubica en Mountain View con un radio de 50 km y confianza medium, así que usableCity() aceptaría la ciudad. Si el radio es amplio o la confianza es low, usá el país para los defaults y dejá la ciudad de lado.
Caché, timeouts y cuota
- Cacheá por IP unas horas. La mayoría del tráfico es de gente que vuelve, así que la caché se come casi todas las consultas.
- Consultá solo donde hace falta: checkout, precios, registro. No en cada asset estático.
- Fail-open: ante un timeout o error, usá un valor por defecto en vez de devolver un 500.
- ¿Procesás logs viejos? Usá
POST /v0/bulkcon hasta 100 IPs por pedido en lugar de un loop de consultas sueltas.
Preguntas frecuentes
¿Por qué me aparece ::ffff:203.0.113.5?
Es una IPv4 mapeada a IPv6 por el socket dual-stack de Node. Sacale el prefijo ::ffff:, como hace la función normalize(). Las IPv6 reales funcionan tal cual.
¿Qué obtengo en desarrollo local?
127.0.0.1 o ::1, direcciones privadas sin ubicación. La API las marca con is_bogon: true. Para probar, fijá una IP pública como 8.8.8.8.
¿Por qué no hacer la consulta desde el navegador?
Le estarías mandando tu API key a cada visitante, los bloqueadores cortan el pedido y el cliente puede falsear el resultado. Hacelo en el servidor, donde ya tenés la IP correcta.
¿Puedo detectar VPN y Tor con la misma llamada?
Sí. La misma respuesta trae traits.is_tor (exit nodes de Tor actuales), is_vpn (redes VPN conocidas, incluidos los principales proveedores comerciales de VPN), is_relay (relay de privacidad) e is_hosting (hosting/datacenter). Ojo: detrás de esas redes, la ubicación es la del punto de salida, no la del usuario.
Relacionado
- API IP a país: código y nombre de país, continente e
is_eu - Detectar registros sospechosos por IP en Python
- Cloudflare Workers: geolocalización de IP y hosting/Tor más allá de request.cf
- API Docs: quickstart, endpoints y referencia completa de campos