Accurate client IP detection in PHP matters for rate limiting, audit trails, fraud review, and geo-aware behavior because the address that reaches the application is not always the browser or device that initiated the request. When the wrong address is stored, reverse-proxy or load-balancer traffic collapses into one source and IP-based controls start making decisions on the last network hop instead of the real client.
For each web request, PHP exposes connection and header data through $_SERVER. $_SERVER['REMOTE_ADDR'] is the connected peer seen by the web-facing runtime, while proxy-added request headers become $_SERVER['HTTP_*'] values such as HTTP_X_FORWARDED_FOR. Direct origins can usually use REMOTE_ADDR as-is, but proxy-aware applications must decide when a forwarded header is trustworthy enough to override the socket peer.
Trust is the critical boundary. The PHP manual notes that most $_SERVER entries are unavailable on CLI, so client-IP logic must be exercised through a real HTTP request path, and forwarded headers should only be honored when the connected peer is a proxy that is explicitly trusted. Validate each candidate with FILTER_VALIDATE_IP, keep the trusted-proxy list exact, and fall back to REMOTE_ADDR for every other request.
Steps to get the client IP address in PHP:
- Read $_SERVER['REMOTE_ADDR'] for direct requests and as the fallback peer address for proxy-aware logic.
$clientIp = $_SERVER['REMOTE_ADDR'] ?? null; if ($clientIp === null) { http_response_code(500); exit('REMOTE_ADDR is unavailable for this request.'); } echo $clientIp, PHP_EOL;
REMOTE_ADDR is the connected peer that reached the web-facing PHP runtime. On a direct request it is usually the client IP, while behind a proxy it is usually the proxy or load-balancer address.
- Add a helper that only inspects X-Forwarded-For after the connected peer matches a trusted proxy.
- client-ip.php
<?php function clientIp(array $trustedProxies = []): ?string { $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; if (!is_string($remoteAddr) || filter_var($remoteAddr, FILTER_VALIDATE_IP) === false) { return null; } $trusted = array_fill_keys($trustedProxies, true); if (!isset($trusted[$remoteAddr])) { return $remoteAddr; } $forwardedFor = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if (!is_string($forwardedFor) || $forwardedFor === '') { return $remoteAddr; } $chain = array_map('trim', explode(',', $forwardedFor)); for ($index = count($chain) - 1; $index >= 0; $index--) { $candidate = $chain[$index]; if ($candidate === '' || filter_var($candidate, FILTER_VALIDATE_IP) === false) { continue; } if (!isset($trusted[$candidate])) { return $candidate; } } return $remoteAddr; }
The same trust rule applies to RFC 7239 Forwarded and vendor-specific proxy headers, but they need their own parser because the field syntax is not the same as X-Forwarded-For.
- Pass the exact reverse-proxy addresses that can connect to the application directly.
$trustedProxies = [ '203.0.113.10', '2001:db8::10', ]; $clientIp = clientIp($trustedProxies); if ($clientIp === null) { http_response_code(400); exit('Unable to determine a valid client IP address.'); } echo $clientIp, PHP_EOL;
Use the real last-hop proxy addresses in production. If the origin is directly reachable from the internet, leave the trusted list empty for security-sensitive decisions. During local verification, temporarily trust 127.0.0.1 or ::1 so the same endpoint can simulate a loopback proxy without exposing production addresses in the sample code.
- Expose a short diagnostic endpoint through the same web path that serves the application while testing the helper.
- client-ip-check.php
<?php require __DIR__ . '/client-ip.php'; header('Content-Type: text/plain'); printf("remote_addr=%s\n", $_SERVER['REMOTE_ADDR'] ?? '(not set)'); printf("http_x_forwarded_for=%s\n", $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '(not set)'); printf("client_ip=%s\n", clientIp(['127.0.0.1']) ?? '(unresolved)');
Remove temporary diagnostic output after testing because it exposes request metadata that should not stay publicly reachable.
- Send a direct request and confirm that the helper falls back to REMOTE_ADDR when no trusted forwarded header is present.
$ curl -s --noproxy app.test --resolve app.test:8765:127.0.0.1 http://app.test:8765/client-ip-check.php remote_addr=127.0.0.1 http_x_forwarded_for=(not set) client_ip=127.0.0.1
--resolve keeps the sample endpoint readable by mapping the masked app.test host to loopback for one request, and --noproxy app.test avoids sending the local check through an exported HTTP proxy.
- Send a request with a trusted X-Forwarded-For chain and confirm that the helper returns the first address that is not in the trusted proxy set.
$ curl -s --noproxy app.test --resolve app.test:8765:127.0.0.1 -H 'X-Forwarded-For: 198.51.100.24, 127.0.0.1' http://app.test:8765/client-ip-check.php remote_addr=127.0.0.1 http_x_forwarded_for=198.51.100.24, 127.0.0.1 client_ip=198.51.100.24
Run the check through the same web server, PHP-FPM pool, or container path that serves production traffic because CLI execution does not populate request metadata like a real HTTP request. The right-to-left walk is safe only when every proxy that can append to the header is in the trusted set; otherwise the first untrusted hop may be another proxy rather than the original client.
Mohd Shakir Zakaria is a cloud architect with deep roots in software development and open-source advocacy. Certified in AWS, Red Hat, VMware, ITIL, and Linux, he specializes in designing and managing robust cloud and on-premises infrastructures.
