MySQL statements you'll come back to
Every PHP tutorial tells you the same thing:
$ip = $_SERVER['REMOTE_ADDR'];
This is right, and also wrong. It's right if your PHP server speaks directly to the visitor. It's wrong on basically every modern hosting setup, where there's a Cloudflare, a load balancer, or a reverse proxy between PHP and the public internet — in which case REMOTE_ADDR is the proxy's IP, not the visitor's.
The naive solution (and why it's dangerous)
You'll see this pattern all over Stack Overflow:
function get_ip() {
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return $_SERVER['HTTP_X_FORWARDED_FOR'];
}
return $_SERVER['REMOTE_ADDR'];
}
This is an IP spoofing vulnerability. X-Forwarded-For is just an HTTP header — anyone can send any value they like:
curl -H "X-Forwarded-For: 1.2.3.4" https://your-site.example/
Now your access logs, your rate limiting, your IP-based admin restrictions, and your fraud-detection rules all think the request came from 1.2.3.4. If you use that IP for anything that matters, you have a problem.
The right way: trust only your own proxy
The rule is simple: X-Forwarded-For can only be trusted if the immediate previous hop (i.e. REMOTE_ADDR) is your own infrastructure. Anything else is unverified user input.
function get_client_ip(array $trusted_proxies = []): string {
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
// If the request didn't come from a trusted proxy,
// REMOTE_ADDR is the truth.
if (!in_array($remote, $trusted_proxies, true)) {
return $remote;
}
// It came from our proxy — read the forwarded chain.
$forwarded = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
$chain = array_map('trim', explode(',', $forwarded));
// Walk the chain from the rightmost (closest to us) backwards,
// skipping any IP we know to be a trusted proxy.
foreach (array_reverse($chain) as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)
&& !in_array($ip, $trusted_proxies, true)) {
return $ip;
}
}
return $remote;
}
// Example usage
$ip = get_client_ip([
'127.0.0.1',
'10.0.0.1',
// Cloudflare's published ranges, your load balancer's IPs, etc.
]);
Cloudflare specifically
Cloudflare puts the real client IP in its own header, CF-Connecting-IP, which they sign and which you can trust if (and only if) the request really did come through Cloudflare:
$ip = $_SERVER['HTTP_CF_CONNECTING_IP']
?? $_SERVER['REMOTE_ADDR'];
Combined with restricting your origin server's firewall to Cloudflare's IP ranges, this is robust. Cloudflare publishes the current ranges at cloudflare.com/ips.
Laravel users
Frameworks already solve this. In Laravel:
$ip = $request->ip();
Just configure trusted proxies in app/Http/Middleware/TrustProxies.php:
protected $proxies = '*'; // if you trust your hosting environment
protected $headers =
Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO;
Always validate
Whatever you end up storing, validate it as an IP first. Anything else is asking for SQL injection in your access log table:
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '0.0.0.0';
}
Bonus: don't store IPs longer than you need
Under UK GDPR, an IP address is personal data. If you log them for security purposes, fine — but write a retention policy (90 days is typical) and stick to it. "We've kept access logs since 2014" is a finding waiting to happen.
If your application does anything based on visitor IP — rate limiting, fraud detection, geographic personalisation — and you'd like a second pair of eyes on whether you're doing it safely, we are happy to take a look.