Tutorial

IP geolocation in JavaScript, Python and PHP

Find the visitor's real IP address, look it up and cache the answer: complete, tested functions for Node.js, Python and PHP, plus batch lookups for log files.

Updated October 29, 2026 · 9 min read

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

  1. 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.
  2. 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:

SetupWhere the client IP is
No proxyThe connection’s address (req.socket.remoteAddress, REMOTE_ADDR)
nginx, HAProxy, cloud load balancersX-Forwarded-For (or X-Real-IP if you set it)
CloudflareCF-Connecting-IP (and X-Forwarded-For)
VercelX-Forwarded-For (Vercel overwrites it)

Two rules keep this safe:

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

Statuserror.codeWhat to do
401missing_api_key, invalid_api_keyCheck the IPBASE_KEY variable.
403plan_upgrade_requiredThe 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.
429quota_exceeded, rate_limit_exceededMonthly 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.

Frequently asked questions

How do I get the location of an IP address in Python?

Send the address to an IP geolocation API, for example requests.get("https://api.ipbase.com/v2/info", params={"ip": ip}, headers={"apikey": key}, timeout=5), and read data.location.country.alpha2 and data.location.city.name from the JSON. Skip private addresses first with ipaddress.ip_address(ip).is_global and cache the answers.

Can I get the user's location in the browser without an API?

The browser's Geolocation API gives a precise position, but only after the user agrees. IP geolocation works without a prompt and is precise enough for country, language and currency defaults.

Why do I always see the same IP address in my logs?

Your app is behind a proxy or load balancer, so the connection comes from the proxy. Read the client IP from the header the proxy sets (X-Forwarded-For, CF-Connecting-IP, X-Real-IP), and only from requests that really come from your proxy: anyone can send these headers.

How many IP addresses can I look up in one request?

POST /v2/batch takes up to 1,000 addresses per request with an API key, on every plan including the free one, and up to 100 in a GET query string. Each valid address counts as one request; repeated addresses in the same batch are looked up once.

Does the free plan include VPN and proxy detection?

No. A free key returns location, connection, time zone and currency data; the security object is in the response, but every field in it is null. The VPN, proxy and Tor flags start with the Medium plan.

IP geolocation API

Get this data in your app

ipbase returns location, ISP, ASN and time zone for any IPv4 or IPv6 address, up to 1,000 addresses per POST request. Security flags (VPN, proxy, Tor, data center) are on the Medium plan and above.

Read the documentation

1,000 free requests every month. No credit card required.

GET https://api.ipbase.com/v2/info?ip=1.1.1.1

{
  "data": {
    "ip": "1.1.1.1",
    "type": "v4",
    "connection": {
      "asn": 13335,
      "organization": "APNIC and Cloudflare DNS Resolver project",
      "isp": "Cloudflare, Inc",
      "range": "1.1.1.0/24"
    },
    "location": {
      "country": { "alpha2": "AU", "name": "Australia" },
      "zip": "4000"
    },
    "security": { "is_vpn": null, "is_tor": null, "threat_score": null }
  }
}

Keep reading

Get your free IP geolocation API key

Start with 1,000 free requests a month