PHP Get IP Address: The Visitor's Real IP in PHP, Laravel, Symfony and WordPress

Updated

Short answer

$_SERVER[‘REMOTE_ADDR’] holds the IP address of the client connected to your web server; it comes from the TCP connection, not from a request header. Behind a reverse proxy, load balancer or CDN it holds the proxy’s address, so restore the visitor’s IP in nginx (real_ip) or Apache (mod_remoteip), or read the forwarding header only when REMOTE_ADDR is one of your proxies. Never trust HTTP_X_FORWARDED_FOR or HTTP_CLIENT_IP on their own: any client can set them.

On this page

PHP gets the visitor’s address from the web server, not from the browser, so the right value depends on what sits between them. This page covers plain PHP, the spoofing bug in the popular header-based snippet, proxies and Cloudflare, Laravel, Symfony, WordPress, validation, the server’s own IP, MySQL storage and IP location. Guides for other languages are on the developer hub.

Get the visitor’s IP address with $_SERVER[‘REMOTE_ADDR’]

The web server fills $_SERVER['REMOTE_ADDR'] with the address at the other end of the TCP connection. A client cannot change it with a request header, because the server’s reply goes back to that address.

PHP
<?php
$ip = $_SERVER['REMOTE_ADDR'] ?? null; // null in CLI scripts and cron jobs
echo $ip ?? 'no HTTP request', PHP_EOL;

Your public IP address

216.73.216.128

Location
Columbus, Ohio, United States
Provider
Anthropic, PBC (AS16509)
Time zone
America/New_York (UTC−4)
Coordinates
39.96, -83.00

This is the address websites see, and the location is an estimate that can be off by a city or more. Your device's local IP (often 192.168.x.x) is a different one and only works inside your network.

The box shows the public IP address of your current connection. A PHP script on a server without a proxy in front would find this address in $_SERVER['REMOTE_ADDR']. If your connection uses IPv6, the box shows an IPv6 address; a server reachable only over IPv4 sees your IPv4 address instead.

It is the public address of the visitor’s network, shared by every device behind the same router. The device’s local IP address, such as 192.168.1.20, never reaches your server unless the visitor is on the same network.

Only the first three keys below come from the connection:

$_SERVER keyHoldsSet by
REMOTE_ADDRIP address of the client or proxy connected to the web serverThe connection
REMOTE_PORTSource port of that connectionThe connection
SERVER_ADDRServer address the request arrived onThe connection
HTTP_X_FORWARDED_FORX-Forwarded-For header: client and proxy addressesThe client, unless your proxy overwrites it
HTTP_X_REAL_IPX-Real-IP header, an nginx conventionThe client, unless your proxy overwrites it
HTTP_CF_CONNECTING_IPVisitor IP sent by CloudflareCloudflare, or anyone who reaches your server directly
HTTP_CLIENT_IPClient-IP, a non-standard headerThe client

The PHP manual on $_SERVER explains the HTTP_ rows: PHP turns every request header into an entry named HTTP_ plus the header name in capitals. getenv('REMOTE_ADDR') returns the same value under PHP-FPM but false under PHP’s built-in server, so use $_SERVER.

Why the HTTP_CLIENT_IP and X-Forwarded-For snippet is unsafe

Most tutorials still copy this function:

PHP
// Do not use: the visitor chooses what this function returns.
function get_client_ip()
{
    if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
        return $_SERVER['HTTP_CLIENT_IP'];
    }
    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        return $_SERVER['HTTP_X_FORWARDED_FOR'];
    }
    return $_SERVER['REMOTE_ADDR'];
}

Both headers come from the request, so one curl command picks the result:

Shell
curl -H "Client-IP: 198.51.100.66" https://www.example.com/ip.php
curl -H "X-Forwarded-For: <script>alert(1)</script>" https://www.example.com/ip.php
Text
198.51.100.66
<script>alert(1)</script>

What that breaks:

  • Rate limits and bans. A bot sends a new address with every request, or another person’s address to get that person blocked.
  • IP allowlists. An admin area limited to your office IP admits anyone who sends that IP in a header.
  • Logs and admin pages. The value can be any text: markup that runs when a log viewer prints it, or a comma-separated list that fails inet_pton() and your database column.
  • Fraud and location checks. Country rules, VPN checks and geolocation start from an address the visitor picked.

WordPress core ships the same pattern as WP_Community_Events::get_unsafe_client_ip(). Core uses it only to suggest nearby events, and its documentation warns that the result “can be easily spoofed.”

Get the real client IP behind a proxy, load balancer or Cloudflare

Behind nginx as a reverse proxy, a load balancer or a CDN, REMOTE_ADDR holds the proxy’s address. The visitor’s IP arrives in a header that the proxy adds: X-Forwarded-For, X-Real-IP or CF-Connecting-IP. To use it safely:

  1. List every proxy in front of PHP: CDN, load balancer, and nginx or Varnish on the same host.
  2. Restore the visitor’s address in one layer: the web server (best), your framework’s trusted proxies, or the client_ip() function below.
  3. Block direct access to the origin, so nobody can skip the proxy and send the header themselves.
  4. Send a forged header with curl, such as X-Forwarded-For: 198.51.100.66, and check that your own IP comes back.

Restore REMOTE_ADDR in nginx or Apache

nginx’s real IP module replaces the connection address with the header value, but only for requests from addresses in set_real_ip_from. PHP-FPM then gets the visitor in REMOTE_ADDR through fastcgi_params, and so do your access logs and every PHP app on the server:

nginx
# server or http block of the nginx in front of PHP-FPM
set_real_ip_from 10.0.0.0/16;   # your load balancer subnet; one line per proxy or CDN range
real_ip_header X-Forwarded-For;
real_ip_recursive on;

With real_ip_recursive on, nginx reads the header from the right and stops at the first address that is not trusted, so a forged entry on the left has no effect. Apache’s mod_remoteip does the same with RemoteIPHeader and RemoteIPInternalProxy. The real client IP guide has the Cloudflare ranges script, the Apache configuration and the header each CDN sends.

Read the header in PHP only from your proxy

If you cannot change the server configuration, check REMOTE_ADDR first and read the header only when the request came from one of your proxies:

PHP
<?php
// The proxies in front of PHP, as REMOTE_ADDR shows them. For CIDR ranges such as
// Cloudflare's, use the CIDR version in the real client IP guide.
const TRUSTED_PROXIES = ['127.0.0.1', '::1'];

function client_ip(string $header = 'HTTP_X_FORWARDED_FOR'): ?string
{
    $ip = $_SERVER['REMOTE_ADDR'] ?? null; // null in CLI scripts and cron jobs
    if ($ip === null || !in_array($ip, TRUSTED_PROXIES, true)) {
        return $ip; // not sent by your proxy: ignore every header
    }
    // Walk the header from the right: your proxy appended the last entry.
    foreach (array_reverse(explode(',', $_SERVER[$header] ?? '')) as $hop) {
        $hop = trim($hop);
        if (filter_var($hop, FILTER_VALIDATE_IP) === false) {
            break; // not an IP address: keep the last good one
        }
        $ip = $hop;
        if (!in_array($hop, TRUSTED_PROXIES, true)) {
            break; // the first address that is not one of your proxies
        }
    }
    return $ip;
}

From any other peer, client_ip() returns REMOTE_ADDR and ignores the headers. From a trusted proxy, it returns the right-most address in the header that is not a proxy: the one your proxy saw. For nginx’s single-value X-Real-IP, call client_ip('HTTP_X_REAL_IP'). How the header grows hop by hop is in the X-Forwarded-For guide.

Tip

Restore the client IP in one layer only. Symfony’s proxy documentation, for example, says its trusted proxies feature does not work as expected together with nginx’s real IP module.

Laravel: $request->ip() and trusted proxies

In Laravel, $request->ip() returns the client IP; request()->ip() and the Request::ip() facade do the same outside controllers. Underneath it is Symfony’s getClientIp(), so it returns REMOTE_ADDR until you trust your proxies in bootstrap/app.php:

PHP
// bootstrap/app.php (Laravel 11 to 13)
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: ['10.0.0.0/16']); // your load balancer subnet
})

// In a controller, middleware or form request
$ip = $request->ip();     // the client, once your proxies are trusted
$chain = $request->ips(); // every untrusted address, the original client last

Use at: '*' only if nothing but your load balancer can reach the app. Laravel then trusts every connection, and ip() returns the left-most X-Forwarded-For entry, which the client controls. Laravel 13 applies that setting by itself on Laravel Cloud and on *.on-forge.com and *.on-vapor.com hosts when you configure no proxies. Getting this right matters for throttling, because Laravel’s documented rate limiters key guests on $request->ip(). Laravel 10 and earlier use $proxies in app/Http/Middleware/TrustProxies.php; see Laravel’s trusted proxies documentation.

Symfony: getClientIp() and trusted_proxies

$request->getClientIp() returns REMOTE_ADDR until framework.trusted_proxies lists your proxies. Keep the list in an environment variable:

YAML
# config/packages/framework.yaml
framework:
    trusted_proxies: '%env(TRUSTED_PROXIES)%'   # .env: TRUSTED_PROXIES=127.0.0.1,10.0.0.0/16
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto']

Then getClientIp() returns the right-most untrusted address, and getClientIps() the whole chain. The value REMOTE_ADDR in trusted_proxies trusts whoever connects, which is safe only when your firewall admits nothing but the load balancer.

WordPress: get the visitor’s IP address

WordPress core has no function that returns the visitor’s IP address. Core reads $_SERVER['REMOTE_ADDR'] directly, for example when wp_new_comment() saves comment_author_IP, and that function’s documentation asks sites behind a proxy to set the value properly, such as in wp-config.php. In a plugin or theme:

PHP
// In a plugin or theme
$ip = isset($_SERVER['REMOTE_ADDR'])
    ? filter_var(wp_unslash($_SERVER['REMOTE_ADDR']), FILTER_VALIDATE_IP)
    : false; // false in WP-CLI or without a valid address

wp_unslash() is there because WordPress adds slashes to $_SERVER. Behind Cloudflare, the official Cloudflare plugin sets REMOTE_ADDR from CF-Connecting-IP on requests from Cloudflare’s address ranges, so core and other plugins see the visitor. Behind Varnish or nginx on the same host, fix it in wp-config.php and accept the header only from that proxy:

PHP
// wp-config.php, between "Add any custom values" and the "stop editing" line
// Varnish or nginx on this host sends the visitor in X-Forwarded-For
if (in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true) && isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
    $hops = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
    $visitor = trim(end($hops)); // the entry your proxy appended
    if (filter_var($visitor, FILTER_VALIDATE_IP)) {
        $_SERVER['REMOTE_ADDR'] = $visitor;
    }
}

Hosting guides often copy X-Forwarded-For into REMOTE_ADDR without the 127.0.0.1 check, which lets every visitor pick an address. WooCommerce’s WC_Geolocation::get_ip_address() returns X-Real-IP or the first X-Forwarded-For entry whenever a request carries them, so keep it away from security decisions. With full-page caching, PHP runs once per cached page, so IP-based output needs an uncached request.

Validate an IP address with filter_var()

filter_var($ip, FILTER_VALIDATE_IP) returns the address if it is a valid IPv4 or IPv6 address, and false otherwise. Values with a port (203.0.113.7:4711), brackets, spaces or several addresses fail. Flags, combined with |, narrow the check, as the PHP manual’s list of validation flags describes:

FlagRejects
FILTER_FLAG_IPV4 / FILTER_FLAG_IPV6The other IP version
FILTER_FLAG_NO_PRIV_RANGE10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
FILTER_FLAG_NO_RES_RANGE0.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 240.0.0.0/4, ::, ::1, fe80::/10
FILTER_FLAG_GLOBAL_RANGE (PHP 8.2+)Everything that is not globally routable under RFC 6890, including 100.64.0.0/10 (carrier-grade NAT) and the documentation ranges
PHP
<?php
var_dump(filter_var('203.0.113.7', FILTER_VALIDATE_IP));      // string(11) "203.0.113.7"
var_dump(filter_var('203.0.113.7:4711', FILTER_VALIDATE_IP)); // bool(false)

$public = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
var_dump(filter_var('192.168.1.20', FILTER_VALIDATE_IP, $public)); // bool(false)
var_dump(filter_var('100.64.0.1', FILTER_VALIDATE_IP, $public));   // string(10) "100.64.0.1"
var_dump(filter_var('100.64.0.1', FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)); // bool(false)

To decide whether an address can have a location, use FILTER_FLAG_GLOBAL_RANGE. The IPv6 lists changed in PHP 8.3.16 and 8.4.3: since then, FILTER_FLAG_NO_RES_RANGE also rejects IPv4-mapped addresses (::ffff:0:0/96) and no longer rejects the documentation prefix 2001:db8::/32. No flag treats ::ffff:10.0.0.1 as private, and an IPv4 client that reaches a server listening on IPv6 for both protocols can show up in that form. Convert such addresses to plain IPv4 first; this function also gives each address one spelling:

PHP
<?php
// '::ffff:203.0.113.7' -> '203.0.113.7', '2001:DB8:0::1' -> '2001:db8::1', 'junk' -> null
function normalize_ip(string $ip): ?string
{
    if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
        return null;
    }
    $bytes = inet_pton($ip);
    if (str_starts_with($bytes, "\0\0\0\0\0\0\0\0\0\0\xff\xff")) {
        $bytes = substr($bytes, 12); // IPv4-mapped IPv6 address
    }
    return inet_ntop($bytes);
}

Get the server’s own IP address in PHP

$_SERVER['SERVER_ADDR'] holds the address the current request arrived on. On a cloud server or behind a load balancer this is usually a private address, and CLI scripts and PHP’s built-in server do not set it. Two more ways from inside PHP:

PHP
<?php
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;

// The address the host name resolves to: IPv4 only, often 127.0.1.1 on Debian and Ubuntu
$hostIp = gethostbyname(gethostname());

// The local address of the interface that routes to the internet. Connecting a UDP
// socket sends no packet; any address outside your network works as the target.
$socket = stream_socket_client('udp://192.0.2.1:53', $errno, $errstr, 1);
$localIp = $socket ? strstr(stream_socket_get_name($socket, false), ':', true) : null; // IPv4

gethostbyname() returns the host name unchanged when it cannot resolve it, so check the result with filter_var(). The server’s public IP is only visible from outside. With iplookupapi, a request without the ip parameter returns the address it came from:

PHP
<?php
$ch = curl_init('https://api.iplookupapi.com/v1/info?fields=ip');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['apikey: ' . getenv('IPLOOKUPAPI_KEY')],
    CURLOPT_TIMEOUT => 5,
]);
$publicIp = json_decode((string) curl_exec($ch), true)['data']['ip'] ?? null;

Cache the result: each call counts as a request.

Store IP addresses in MySQL

Use a VARBINARY(16) column: IPv4 takes 4 bytes and IPv6 16. MySQL’s INET6_ATON() and INET6_NTOA() convert in SQL, so PHP only handles text. With client_ip() and normalize_ip() from above:

PHP
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', 'app', getenv('DB_PASSWORD'), [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$pdo->exec('CREATE TABLE IF NOT EXISTS visits (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    ip VARBINARY(16) NOT NULL,
    seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_ip (ip)
)');

// Write: MySQL turns the text into 4 (IPv4) or 16 (IPv6) bytes
$ip = normalize_ip(client_ip() ?? '');
if ($ip !== null) {
    $pdo->prepare('INSERT INTO visits (ip) VALUES (INET6_ATON(?))')->execute([$ip]);
}

// Read: back to text, newest first
foreach ($pdo->query('SELECT INET6_NTOA(ip) AS ip, seen_at FROM visits ORDER BY id DESC LIMIT 10') as $row) {
    echo $row['ip'], ' ', $row['seen_at'], PHP_EOL;
}

// Find one address
$count = $pdo->prepare('SELECT COUNT(*) FROM visits WHERE ip = INET6_ATON(?)');
$count->execute(['203.0.113.7']);
echo $count->fetchColumn(), PHP_EOL;

inet_pton() and inet_ntop() do the same conversion in PHP. Binding their bytes as query parameters works too, but MySQL 8 then reports warning 1300 (Invalid utf8mb4 character string) on a utf8mb4 connection. If you store text, VARCHAR(45) fits every notation, including an IPv4-mapped address written out in full.

Anonymize IP addresses before you keep them

Under the GDPR, an IP address is usually personal data: Recital 30 names IP addresses among the online identifiers that can identify people. Keep full addresses only as long as you need them, for example to handle abuse, and shorten them for statistics. This function keeps the /24 network of an IPv4 address and the /64 of an IPv6 address, like WordPress’s wp_privacy_anonymize_ip() and Symfony’s IpUtils::anonymize():

PHP
<?php
// 203.0.113.77 -> 203.0.113.0, 2001:db8:85a3:8d3:1319:8a2e:370:7348 -> 2001:db8:85a3:8d3::
// Uses normalize_ip() from the validation section.
function anonymize_ip(string $ip): ?string
{
    $ip = normalize_ip($ip);
    if ($ip === null) {
        return null;
    }
    $bytes = inet_pton($ip);
    $keep = strlen($bytes) === 4 ? 3 : 8; // IPv4: 3 of 4 bytes, IPv6: 8 of 16
    return inet_ntop(substr($bytes, 0, $keep) . str_repeat("\0", strlen($bytes) - $keep));
}

A shortened address still points to a network; how long you may keep it depends on your purpose and on what you store with it.

Get the location from the visitor’s IP address

To get the visitor’s country, city or time zone, send the address to an IP lookup API from your server. With iplookupapi, the key stays in the IPLOOKUPAPI_KEY environment variable, and fields limits the response to what you use:

PHP
<?php
// FILTER_FLAG_GLOBAL_RANGE needs PHP 8.2 or later
function ip_location(string $ip): ?array
{
    // Private, reserved and invalid addresses have no location
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) === false) {
        return null;
    }
    $ch = curl_init('https://api.iplookupapi.com/v1/info?' . http_build_query([
        'ip' => $ip,
        'fields' => 'location.country.alpha2,location.city.name,timezone.id',
    ]));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['apikey: ' . getenv('IPLOOKUPAPI_KEY')],
        CURLOPT_CONNECTTIMEOUT => 2,
        CURLOPT_TIMEOUT => 3,
    ]);
    $body = curl_exec($ch);
    if ($body === false || curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
        return null; // timeout, 401 (key), 429 (rate limit or quota): carry on without it
    }
    return json_decode($body, true)['data'] ?? null;
}

// client_ip() from the proxy section above
$location = ip_location(client_ip() ?? '');
echo $location['location']['country']['alpha2'] ?? 'unknown', PHP_EOL;

A slow or failed lookup returns null within three seconds. The PHP SDK guide covers the official package, caching, batches and Laravel recipes; the info endpoint reference lists every field; and the IP to country API page covers country codes, currencies and EU checks. For VPN, proxy and Tor flags at signup or checkout, see the IP reputation API.

Troubleshooting wrong or empty IP addresses in PHP

You seeCauseFix
127.0.0.1 or ::1Browser and server on one machine, or a proxy on the same host (nginx in front of Apache, Varnish)Normal in development; in production, trust that proxy
A CDN or load balancer addressThe proxy opened the connectionRestore the IP in nginx or Apache, or set trusted proxies
Undefined array key "REMOTE_ADDR"CLI script, cron job or queue worker: no HTTP requestUse ?? null, and pass the IP from the request into the job
An IPv6 addressThe visitor connected over IPv6Normal; store it in VARBINARY(16) or VARCHAR(45)
::ffff:203.0.113.7IPv4 client on an IPv6 socketConvert it with normalize_ip()
A new IP on each visit for one userMobile networks, carrier-grade NAT, IPv6 privacy addresses, VPNsDo not use the IP as a user ID; rate-limit IPv6 by /64
Several addresses in one valueCode reads X-Forwarded-For directlyUse client_ip() or your framework’s trusted proxies

Frequently asked questions

How do I get the visitor's IP address in PHP?

Read $_SERVER[‘REMOTE_ADDR’]. The web server sets it from the TCP connection, so a visitor cannot fake it with a request header. Behind a reverse proxy, load balancer or CDN it holds the proxy’s address; then restore the visitor’s IP in nginx or Apache, or read X-Forwarded-For only when REMOTE_ADDR is one of your proxies, as the real client IP guide shows.

Is $_SERVER['REMOTE_ADDR'] reliable?

It reliably shows who opened the connection, because it comes from the TCP connection and not from a header. That is not always the person: behind a proxy it is the proxy, and VPNs, carrier-grade NAT and mobile networks put many people behind one address. Use it for logs, rate limits and rough location, not as proof of identity.

How do I get the client IP behind Cloudflare in PHP?

Cloudflare sends the visitor’s address in the CF-Connecting-IP header, which PHP shows as $_SERVER[‘HTTP_CF_CONNECTING_IP’]. Trust it only when REMOTE_ADDR is in Cloudflare’s published IP ranges, because anyone who reaches your server directly can send that header. The cleanest fix is nginx’s real IP module or Apache’s mod_remoteip with Cloudflare’s ranges; on WordPress, the official Cloudflare plugin restores REMOTE_ADDR for requests from those ranges.

How do I validate an IP address in PHP?

Use filter_var($ip, FILTER_VALIDATE_IP), which returns the address or false. Add FILTER_FLAG_IPV4 or FILTER_FLAG_IPV6 to allow one version, FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE to reject private and reserved ranges, or FILTER_FLAG_GLOBAL_RANGE (PHP 8.2 and later) to accept only globally routable addresses. Values with a port, such as 203.0.113.7:4711, fail validation.

How do I store an IP address in MySQL?

Use a VARBINARY(16) column, write with INET6_ATON() and read with INET6_NTOA(): IPv4 addresses take 4 bytes, IPv6 addresses 16. In PHP, inet_pton() and inet_ntop() do the same conversion. If you prefer text, a VARCHAR(45) column fits every IPv4 and IPv6 notation.

How do I get the visitor's IP address in Laravel or WordPress?

In Laravel, call $request->ip() or request()->ip(), and list your load balancers or proxies with $middleware->trustProxies(at: […]) in bootstrap/app.php. WordPress core has no function for it: read $_SERVER[‘REMOTE_ADDR’] and make sure your setup puts the visitor’s address there, for example with the official Cloudflare plugin or a proxy check in wp-config.php.

Look up IP addresses in your code

iplookupapi returns the location, provider, time zone and security flags of any IP address as JSON. The free plan includes 150 requests a month.

curl "https://api.iplookupapi.com/v1/info?ip=8.8.8.8" \
  -H "apikey: YOUR_API_KEY"