Skip to main content
Being Idea Innovations
Find a Visitor's Location from Their IP Address in PHP (Safely)
Back to Blog

Find a Visitor's Location from Their IP Address in PHP (Safely)

23 January 20186 min readPHP
Share:

Looking up a visitor's approximate location from their IP address is useful for setting a default currency, pre-filling a country field, routing enquiries to the right office, or showing regionally relevant content. It is also an area where the most widely copied PHP snippet contains a serious security hole.

This guide covers IP geolocation in PHP correctly: which service to use, how to obtain the real client IP when you sit behind a proxy or CDN, and what accuracy you can honestly expect.

Start with the dangerous version

This pattern appears in countless tutorials, including the original version of this article:

// DO NOT USE THIS
echo var_export(
    unserialize(
        file_get_contents('http://www.geoplugin.net/php.gp?ip=' . $_SERVER['REMOTE_ADDR'])
    )
);

It has three distinct problems, and the first is severe.

1. unserialize() on remote data is a code execution risk

PHP's unserialize() reconstructs objects. If an attacker controls the serialized string and your codebase contains a class with a __destruct(), __wakeup() or __toString() method that does something consequential, they can chain those into arbitrary file writes or command execution. This is PHP object injection, and it is a well-documented attack class.

Here the string comes over plain HTTP from a third party. Anyone able to intercept that connection — on a shared network, a compromised upstream router, a hostile DNS response — controls what your server deserializes. Never call unserialize() on anything you did not produce yourself. Use JSON, which cannot instantiate objects.

2. Plain HTTP

The request goes out unencrypted, so the response can be modified in transit. Always use https://.

3. No timeout and no error handling

A bare file_get_contents() uses default_socket_timeout, which is 60 seconds. If the geolocation service is slow or unreachable, every page load on your site blocks for a minute. Combined with the @ suppression operator that usually accompanies these snippets, failures are silent and the cause is invisible.

A safe implementation

<?php
declare(strict_types=1);

function lookupLocation(string $ip, int $timeoutSeconds = 2): ?array
{
    // Never send private or reserved addresses to a geolocation API — the
    // answer is meaningless and you leak your internal addressing.
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        return null;
    }

    $context = stream_context_create([
        'http' => [
            'timeout'       => $timeoutSeconds,
            'ignore_errors' => true,
            'header'        => "User-Agent: beingidea-geo/1.0\r\n",
        ],
    ]);

    $url = 'https://ssl.geoplugin.net/json.gp?ip=' . urlencode($ip);
    $body = @file_get_contents($url, false, $context);

    if ($body === false) {
        return null;
    }

    // json_decode cannot instantiate objects, so a hostile response is inert.
    $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);

    if (!is_array($data) || ($data['geoplugin_status'] ?? 0) >= 400) {
        return null;
    }

    return [
        'country'      => $data['geoplugin_countryName']  ?: null,
        'country_code' => $data['geoplugin_countryCode']  ?: null,
        'city'         => $data['geoplugin_city']         ?: null,
        'region'       => $data['geoplugin_regionName']   ?: null,
        'latitude'     => isset($data['geoplugin_latitude'])  ? (float) $data['geoplugin_latitude']  : null,
        'longitude'    => isset($data['geoplugin_longitude']) ? (float) $data['geoplugin_longitude'] : null,
        'currency'     => $data['geoplugin_currencyCode'] ?: null,
    ];
}

Note the FILTER_FLAG_NO_PRIV_RANGE check. During local development REMOTE_ADDR is 127.0.0.1 or a 192.168.x.x address, and sending those to a geolocation API returns nothing useful. Rejecting them early also stops you from leaking internal network structure to a third party.

Getting the real client IP

$_SERVER['REMOTE_ADDR'] is the address of whatever connected to your web server. If you are behind Cloudflare, a load balancer, or nginx acting as a reverse proxy, that is the proxy's address — every visitor appears to come from the same place.

The tempting fix is to read X-Forwarded-For. Do not do it naively:

// DO NOT USE THIS
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];

X-Forwarded-For is a request header, and request headers are supplied by the client. Anyone can send X-Forwarded-For: 8.8.8.8. If you use that value for anything security-relevant — rate limiting, geo-restrictions, audit logs, fraud checks — you have handed the attacker a bypass.

Only trust the header when the request genuinely arrived from a proxy you control:

function clientIp(array $trustedProxies): string
{
    $remote = $_SERVER['REMOTE_ADDR'] ?? '';

    // Only consider forwarding headers if the immediate peer is a proxy we trust.
    if (!in_array($remote, $trustedProxies, true)) {
        return $remote;
    }

    // Cloudflare's header contains a single, trustworthy value.
    if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
        return $_SERVER['HTTP_CF_CONNECTING_IP'];
    }

    // X-Forwarded-For is "client, proxy1, proxy2" — the first entry is the client.
    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $parts = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
        $candidate = $parts[0] ?? '';
        if (filter_var($candidate, FILTER_VALIDATE_IP)) {
            return $candidate;
        }
    }

    return $remote;
}

If you use Cloudflare, there is a shortcut worth knowing: it sends a CF-IPCountry header on every request containing the two-letter country code. It is free, adds no latency, and requires no API call at all. For country-level decisions that is all you need.

Cache the result

An external HTTP request on every page load is a latency and rate-limit problem waiting to happen. Geolocation for a given IP changes rarely, so cache it:

function cachedLocation(string $ip, Redis $redis): ?array
{
    $key = 'geo:' . $ip;

    $hit = $redis->get($key);
    if ($hit !== false) {
        return json_decode($hit, true);
    }

    $location = lookupLocation($ip);
    if ($location !== null) {
        $redis->setex($key, 86400 * 7, json_encode($location));
    }

    return $location;
}

Storing it in the session works too if you only need it once per visit. Either way, the lookup should happen once, not on every request.

Choosing a provider

  • geoPlugin — free, no key required, but rate limited and its accuracy has declined. Fine for a low-traffic site or a non-critical default.
  • MaxMind GeoLite2 — a free downloadable database you query locally. No network call, no rate limit, no third party seeing your visitors' addresses. Requires an account and periodic updates, and is the right answer for anything high traffic.
  • Cloudflare CF-IPCountry — free and instant if you already use Cloudflare. Country only.
  • Commercial APIs (ipinfo, ipapi, MaxMind's paid tiers) — better accuracy and an SLA, at a cost.

For a local database with MaxMind:

composer require geoip2/geoip2
use GeoIp2\Database\Reader;

$reader = new Reader('/usr/share/GeoIP/GeoLite2-City.mmdb');

try {
    $record = $reader->city($ip);
    $country = $record->country->name;
    $city    = $record->city->name;
} catch (\GeoIp2\Exception\AddressNotFoundException) {
    $country = $city = null;
}

Be honest about accuracy

IP geolocation is an estimate, not a fact:

  • Country level is roughly 95–99% accurate. Good enough to pick a default currency or language.
  • City level is far weaker — frequently 50–80%, and often resolves to the ISP's registered location rather than the user's. India in particular tends to resolve to a national centroid, which is why the original snippet's output showed latitude 20, longitude 77 with no city at all: that is the geographic middle of the country, not where the visitor was.
  • VPNs, mobile carriers, corporate networks and privacy relays (iCloud Private Relay is now widespread) all report the wrong place by design.

So treat it as a default, never a gate. Pre-select a country in a dropdown — do not block access or hide the option to change it. If you need real location, ask the browser for it via the Geolocation API, which is accurate to metres and, crucially, asks the user's permission.

Legal note

Under GDPR an IP address is personal data. Sending visitor IPs to a third-party API is a data transfer that belongs in your privacy policy, and if that provider is outside your jurisdiction you may need a lawful transfer mechanism. A local MaxMind database avoids the transfer entirely, which is a genuine argument for it beyond performance.

Summary

Use JSON and HTTPS with an explicit timeout — never unserialize() a remote response. Validate the IP and skip private ranges. Only trust X-Forwarded-For when the request came from a proxy you control. Cache results. And treat the answer as a helpful guess rather than something to make decisions on behalf of your users.

Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers