Previous Article
Alphabetical A–Z Listings from MySQL in PHP (Without 27 Queries)
16 January 2018
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.
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.
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.
The request goes out unencrypted, so the response can be modified in transit. Always use https://.
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.
<?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.
$_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.
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.
CF-IPCountry — free and instant if you already use Cloudflare. Country only.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;
}
IP geolocation is an estimate, not a fact:
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.
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.
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.
Advertisement
Written by
Amit VermaFounder / 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 AmitYou might also like
We ship software that scales. Let's work together.