How to get the client IP address in PHP

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:

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

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

  3. 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.

  4. 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.

  5. 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.

  6. 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.