IP geolocation in JavaScript, Python and PHP takes three steps: find the visitor’s real IP address (behind proxies that is not the connection address), send it to an IP geolocation API, and cache the answer so a returning visitor costs nothing. This guide shows all three for Node.js, Python and PHP with the ipbase API, including error handling, private addresses and batch lookups for log files.
We ran every snippet against a local stub of the API that returns the documented responses: the Node.js code with Node.js 26 and Express 5, the Python code with Python 3.15, requests and Flask, the PHP code with PHP 8.5.
Before you start
- Create a free account and copy your API key. The free plan includes 1,000 requests a month (10 a minute) with location, connection, time zone and currency data, and no credit card.
- Put the key in an environment variable, never in your code:
export IPBASE_KEY="your-api-key"
Every example sends the key in the apikey header, which keeps it out of URLs and access logs. The endpoint is GET https://api.ipbase.com/v2/info?ip=...; this is the part of its answer the examples read:
{
"data": {
"ip": "80.110.0.1",
"connection": { "asn": 8412, "organization": "Magenta Telekom GmbH", "isp": "Magenta Telekom" },
"location": {
"country": { "alpha2": "AT", "name": "Austria", "is_in_european_union": true },
"region": { "alpha2": "AT-9", "name": "Vienna" },
"city": { "name": "Vienna" },
"precision": "city",
"accuracy_radius_km": 3.1
},
"timezone": { "id": "Europe/Vienna" }
}
}
The full object (time zone, currency, languages, security flags) is in the info endpoint documentation. Official SDKs exist for JavaScript, Python and PHP (@everapi/ipbase-js, ipbase, everapi/ipbase-php); the examples below use plain HTTP so you control timeouts, errors and caching.
1. Find the real client IP
Behind a reverse proxy, a load balancer or a CDN, the connection comes from the proxy. The visitor’s address is in a header the proxy adds:
| Setup | Where the client IP is |
|---|---|
| No proxy | The connection’s address (req.socket.remoteAddress, REMOTE_ADDR) |
| nginx, HAProxy, cloud load balancers | X-Forwarded-For (or X-Real-IP if you set it) |
| Cloudflare | CF-Connecting-IP (and X-Forwarded-For) |
| Vercel | X-Forwarded-For (Vercel overwrites it) |
Two rules keep this safe:
- Trust the header only from your own proxies. Any client can send
X-Forwarded-For: 1.2.3.4. If your server is also reachable directly, check that the connection comes from your proxy (for Cloudflare, from the Cloudflare IP ranges) before you read the header. - Read
X-Forwarded-Forfrom the right. Each proxy appends the address it received the request from. The first entry is whatever the client sent; the right-most entry your own proxy added is the one you can trust.
Frameworks do this for you once you tell them how many proxies are in front: app.set("trust proxy", 1) in Express, ProxyFix(app.wsgi_app, x_for=1) in Flask, TrustProxies in Laravel. In our test, Express with trust proxy set to 1 returned 80.110.0.1 as req.ip for X-Forwarded-For: 6.6.6.6, 80.110.0.1 and ignored the forged first entry.
2. IP geolocation in Node.js
Node.js 18 and later have fetch built in. The function skips addresses that have no location, caches answers for a day and turns API errors into exceptions:
// geolocate.mjs – Node.js 18 or later (built-in fetch)
import { BlockList, isIP } from "node:net";
const API = "https://api.ipbase.com/v2/info";
const FIELDS = "location.country.alpha2,location.region.name,location.city.name,connection.isp,timezone.id";
const TTL_MS = 24 * 60 * 60 * 1000; // keep answers for a day
const cache = new Map(); // ip -> { at, value }
// Addresses without a location: private, loopback, link-local and carrier-grade NAT
const noLocation = new BlockList();
noLocation.addSubnet("10.0.0.0", 8, "ipv4");
noLocation.addSubnet("172.16.0.0", 12, "ipv4");
noLocation.addSubnet("192.168.0.0", 16, "ipv4");
noLocation.addSubnet("127.0.0.0", 8, "ipv4");
noLocation.addSubnet("169.254.0.0", 16, "ipv4");
noLocation.addSubnet("100.64.0.0", 10, "ipv4");
noLocation.addSubnet("::1", 128, "ipv6");
noLocation.addSubnet("fc00::", 7, "ipv6");
noLocation.addSubnet("fe80::", 10, "ipv6");
export async function geolocate(rawIp) {
const ip = rawIp.replace(/^::ffff:/, ""); // Node reports IPv4 clients as ::ffff:203.0.113.7
const family = isIP(ip);
if (!family || noLocation.check(ip, family === 4 ? "ipv4" : "ipv6")) return null;
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
const res = await fetch(`${API}?${new URLSearchParams({ ip, fields: FIELDS })}`, {
headers: { apikey: process.env.IPBASE_KEY },
signal: AbortSignal.timeout(5000),
});
const body = await res.json();
if (!res.ok) {
// 401 invalid key, 422 invalid IP, 429 quota or rate limit: error.code says which
throw new Error(`ipbase ${res.status}: ${body.error?.code ?? body.message}`);
}
const { location, connection, timezone } = body.data;
const value = {
country: location?.country?.alpha2 ?? null,
region: location?.region?.name ?? null,
city: location?.city?.name ?? null,
isp: connection?.isp ?? null,
timezone: timezone?.id ?? null,
};
cache.set(ip, { at: Date.now(), value });
return value;
}
In an Express app behind one proxy:
// server.mjs – Express 5
import express from "express";
import { geolocate } from "./geolocate.mjs";
const app = express();
app.set("trust proxy", 1); // exactly one proxy (nginx, a load balancer) in front of the app
app.get("/", async (req, res) => {
const geo = await geolocate(req.ip).catch((err) => {
console.error(err.message);
return null; // fail open: the page works without a location
});
res.json({ ip: req.ip, geo });
});
app.listen(3000);
The Map grows with every new address. That is fine for a script or a small app; for a busy server, use an LRU cache (the lru-cache package) or Redis with a one-day expiry.
3. IP geolocation in Python
ipaddress from the standard library tells you whether an address is public, so private and carrier-grade NAT addresses never cost a request:
# geo.py – Python 3.10 or later, pip install requests
import ipaddress
import os
from functools import lru_cache
import requests
API = "https://api.ipbase.com/v2/info"
FIELDS = "location.country.alpha2,location.region.name,location.city.name,connection.isp,timezone.id"
session = requests.Session()
session.headers["apikey"] = os.environ["IPBASE_KEY"]
class IpbaseError(Exception):
pass
@lru_cache(maxsize=10_000)
def geolocate(ip: str) -> dict | None:
try:
if not ipaddress.ip_address(ip).is_global:
return None # private, loopback, carrier-grade NAT: no location
except ValueError:
return None # not an IP address
res = session.get(API, params={"ip": ip, "fields": FIELDS}, timeout=5)
body = res.json()
if res.status_code != 200:
# 401 invalid key, 422 invalid IP, 429 quota or rate limit
code = (body.get("error") or {}).get("code") or body.get("message")
raise IpbaseError(f"ipbase {res.status_code}: {code}")
data = body["data"]
location = data.get("location") or {}
return {
"country": (location.get("country") or {}).get("alpha2"),
"region": (location.get("region") or {}).get("name"),
"city": (location.get("city") or {}).get("name"),
"isp": (data.get("connection") or {}).get("isp"),
"timezone": (data.get("timezone") or {}).get("id"),
}
geolocate("80.110.0.1") returned {'country': 'AT', 'region': 'Vienna', 'city': 'Vienna', 'isp': 'Magenta Telekom', 'timezone': 'Europe/Vienna'} against the stub. lru_cache keeps answers for the lifetime of the process and does not cache exceptions; for a long-running service that should forget answers after a day, use cachetools.TTLCache instead.
In Flask, ProxyFix turns the proxy’s X-Forwarded-For into request.remote_addr:
# app.py – Flask behind one reverse proxy
from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix
from geo import IpbaseError, geolocate
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) # trust one X-Forwarded-For hop
@app.get("/")
def home():
try:
geo = geolocate(request.remote_addr)
except (IpbaseError, OSError):
geo = None # fail open
return {"ip": request.remote_addr, "geo": geo}
In Django, set the client address from request.META["HTTP_X_FORWARDED_FOR"] in a middleware with the same right-to-left rule, or use a package such as django-ipware.
4. IP geolocation in PHP
PHP 8.2 added FILTER_FLAG_GLOBAL_RANGE, which rejects private, reserved and carrier-grade NAT addresses in one check. The cache uses APCu when the extension is installed:
<?php
// geo.php – PHP 8.2 or later with the curl extension
function geolocate(string $ip): ?array
{
// invalid, private, reserved or carrier-grade NAT: no location
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
return null;
}
$cacheKey = "ipbase:$ip";
if (function_exists('apcu_fetch')) {
$cached = apcu_fetch($cacheKey, $found);
if ($found) {
return $cached;
}
}
$query = http_build_query([
'ip' => $ip,
'fields' => 'location.country.alpha2,location.region.name,location.city.name,connection.isp,timezone.id',
]);
$ch = curl_init("https://api.ipbase.com/v2/info?$query");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['apikey: ' . getenv('IPBASE_KEY')],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 5,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException('ipbase: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if ($status !== 200) {
// 401 invalid key, 422 invalid IP, 429 quota or rate limit
throw new RuntimeException("ipbase $status: " . ($json['error']['code'] ?? $json['message']));
}
$data = $json['data'];
$result = [
'country' => $data['location']['country']['alpha2'] ?? null,
'region' => $data['location']['region']['name'] ?? null,
'city' => $data['location']['city']['name'] ?? null,
'isp' => $data['connection']['isp'] ?? null,
'timezone' => $data['timezone']['id'] ?? null,
];
if (function_exists('apcu_store')) {
apcu_store($cacheKey, $result, 86400); // a day
}
return $result;
}
There is no curl_close(): since PHP 8.0 the handle is freed automatically, and PHP 8.5 deprecates the function. Laravel and Symfony read the client IP for you once you configure their trusted proxies ($request->ip()). Without a framework, read X-Forwarded-For only when the connection comes from one of your proxies:
<?php
// The client IP behind your own reverse proxies (list their addresses)
function clientIp(array $trustedProxies): string
{
$remote = $_SERVER['REMOTE_ADDR'];
if (!in_array($remote, $trustedProxies, true)) {
return $remote; // not from your proxy: ignore X-Forwarded-For
}
// each proxy appends the address it saw: walk from the right, skip your own proxies
$hops = array_reverse(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')));
foreach ($hops as $hop) {
if (filter_var($hop, FILTER_VALIDATE_IP) && !in_array($hop, $trustedProxies, true)) {
return $hop;
}
}
return $remote;
}
$geo = geolocate(clientIp(['10.0.0.2']));
5. Look up many addresses at once
For log files, sign-up lists or analytics exports, send the addresses to the batch endpoint: up to 1,000 addresses per POST with an API key (the free plan’s too), or 100 in a GET query string. Each valid address counts as one request, the X-Cost response header shows the total, and repeated addresses in one batch are looked up once. An entry that is not an IP address does not fail the batch: it comes back in errors and is not billed.
# batch.py – look up a list of addresses, up to 1,000 per POST
import os
import requests
BATCH_URL = "https://api.ipbase.com/v2/batch"
BATCH_SIZE = 1000
def geolocate_many(ips: list[str]) -> dict[str, dict]:
unique = list(dict.fromkeys(ips)) # keep the order, drop repeats
results: dict[str, dict] = {}
with requests.Session() as session:
session.headers["apikey"] = os.environ["IPBASE_KEY"]
for start in range(0, len(unique), BATCH_SIZE):
res = session.post(
BATCH_URL,
params={"fields": "ip,location.country.alpha2,location.city.name,connection.asn"},
json={"ips": unique[start : start + BATCH_SIZE]},
timeout=60,
)
res.raise_for_status()
body = res.json()
results.update(body["data"])
for item, reason in body.get("errors", {}).items():
print(f"skipped {item!r}: {reason}") # not looked up, not billed
return results
Without code, paste a list or a log file into the free bulk IP lookup (10 addresses per run, with CSV export).
6. Ask only for the fields you need
The fields parameter takes comma-separated dot paths, such as location.country.alpha2,connection.asn, and returns only those values. Naming an object (security) keeps it whole, and unknown paths are ignored. Smaller answers parse faster and keep your cache small; every example above uses it.
7. Errors, limits and plans
| Status | error.code | What to do |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | Check the IPBASE_KEY variable. |
| 403 | plan_upgrade_required | The endpoint is not in your plan (for example the ASN or hosted domains API). |
| 422 | (validation errors) | The ip value is not an IP address; validate it before the call. |
| 429 | quota_exceeded, rate_limit_exceeded | Monthly quota used up (quota.resets_at says when it comes back), or the free plan’s limit of 10 requests a minute (wait for Retry-After). |
Only successful calls count against your quota; validation errors and errors on our side do not. The status codes documentation lists every answer.
A free key returns location, connection, time zone and currency data. The security object (VPN, proxy, Tor, data center, threat score) is part of the Medium plan and above; with a free key every field in it is null, so code that blocks VPN users silently lets everyone through. The VPN detection guide shows how to handle that. Plans and quotas are on the pricing page.