# How to detect VPN users in PHP and WordPress

Detect VPN, proxy and Tor visitors in PHP and WordPress with our PHP library, tested live, and close the client-IP bypass the usual snippet leaves open.

This tutorial adds VPN detection to a PHP site with [`vpndetection/vpndetection`](https://docs.vpndetection.io/integrations/php), our official PHP library, and then to WordPress's signup form and WooCommerce's checkout, without a plugin from the directory. By the end, a signup from a VPN, residential proxy or Tor exit is asked to confirm its email in plain PHP. In WordPress, Tor is refused at signup, VPN and proxy accounts are flagged in Users, and WooCommerce orders from them get a note. On the way, it fixes the two ways PHP usually gets the visitor's address wrong: behind Cloudflare every visitor is Cloudflare, and the snippet that comes up to fix that lets anyone on a VPN name any address they like. We ran every snippet against the live API on October 3, 2026, with PHP 8.4.26, WordPress 7.1.2, WooCommerce 11.1.2 and `vpndetection/vpndetection` 4.4.2, and every output below is from those runs.

## Look up an address

```bash
composer require vpndetection/vpndetection
```

The library needs PHP 8.2 or newer. Get an API key in the [console](https://app.vpndetection.io): a free one covers 50,000 lookups a month. Without a key, the API allows 1,000 lookups a day per source address, and once deployed, every lookup comes from your server's address.

```php
<?php
// lookup.php
require __DIR__ . '/vendor/autoload.php';

use VPNDetection\Client;
use VPNDetection\Options;

$client = new Client(new Options(apiKey: getenv('VPNDETECTION_API_KEY') ?: null));

$result = $client->lookup($argv[1]);
echo json_encode($result->raw, JSON_PRETTY_PRINT), "\n";
```

With the key in `VPNDETECTION_API_KEY`, look up `23.165.56.3`, a Mullvad server in New York from [Mullvad's own server list](https://mullvad.net/en/servers):

```bash
php lookup.php 23.165.56.3 | jq '{is_vpn, is_hosting, is_tor, is_resproxy, vpn}'
```

```json
{
  "is_vpn": true,
  "is_hosting": false,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "provider": "mullvad",
    "last_seen": "2026-10-02",
    "confidence": "high",
    "method": "scan"
  }
}
```

That's a key on the Max plan. The answer also has `is_relay`, `is_cdn`, `is_dcproxy` and `is_mobproxy`, and a detail object under each flag. `$result->raw` is the answer as the API sent it; in code, read the typed properties, `$result->isVpn` and `$result->vpn->provider`. A home broadband address on a US cable ISP came back with every flag `false`. A private or reserved address never reaches the API: the library answers it itself, with `isBogon` set.

## Get the client address right

PHP puts the address that opened the connection in `$_SERVER['REMOTE_ADDR']`. With nothing in front of your server, that's the visitor. Behind Cloudflare, it's Cloudflare. Our test app sat behind a stand-in for Cloudflare: requests from an address in Cloudflare's published ranges, carrying the `CF-Connecting-IP` and `X-Forwarded-For` headers Cloudflare adds. `REMOTE_ADDR` looked up as `173.245.48.10`, with `is_cdn` true, whoever the visitor was.

The code that comes up for this is the second answer to Stack Overflow's [How to get the client IP address in PHP](https://stackoverflow.com/questions/3003145/how-to-get-the-client-ip-address-in-php), a question viewed 2.3 million times:

```php
function get_client_ip()
{
    if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
        $ip = $_SERVER['HTTP_CLIENT_IP'];
    } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
    } else {
        $ip = $_SERVER['REMOTE_ADDR'];
    }
    return $ip;
}
```

Its 566 votes are for code that an editor's note under it warns against. `HTTP_CLIENT_IP` and `HTTP_X_FORWARDED_FOR` are the `Client-IP` and `X-Forwarded-For` request headers, and the visitor can write both. Cloudflare [passes every request header](https://developers.cloudflare.com/fundamentals/reference/http-headers/) on to your server, so a visitor on a VPN sets `Client-IP` to someone else's address, and that's the address you look up.

Cloudflare puts the address that connected to it in `CF-Connecting-IP`. Read it only when the request came from Cloudflare, whose addresses are published at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Save them next to your code:

```bash
{ curl -s https://www.cloudflare.com/ips-v4; echo
  curl -s https://www.cloudflare.com/ips-v6; echo; } > cloudflare-ips.txt
```

The `echo`s are there because neither list ends in a newline. On October 3 that made 22 ranges, 15 IPv4 and 7 IPv6; fetch them again when you deploy.

```php
<?php
// client_ip.php
namespace VpnCheck;

/**
 * The visitor's address: Cloudflare's header when the request came from
 * Cloudflare, otherwise the address that opened the connection.
 */
function client_ip(): string
{
    $peer = $_SERVER['REMOTE_ADDR'];
    $header = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? null;
    if ($header !== null && from_cloudflare($peer)) {
        return $header;
    }
    return $peer;
}

function from_cloudflare(string $ip): bool
{
    $file = __DIR__ . '/cloudflare-ips.txt';
    foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $range) {
        if (in_range($ip, $range)) {
            return true;
        }
    }
    return false;
}

function in_range(string $ip, string $cidr): bool
{
    [$net, $bits] = explode('/', $cidr);
    $ip = inet_pton($ip);
    $net = inet_pton($net);
    if (strlen($ip) !== strlen($net)) {
        return false; // an IPv4 address against an IPv6 range, or the reverse
    }
    $whole = intdiv((int) $bits, 8);
    if (substr($ip, 0, $whole) !== substr($net, 0, $whole)) {
        return false;
    }
    $rest = (int) $bits % 8;
    $mask = (0xff << (8 - $rest)) & 0xff;
    return $rest === 0 || (ord($ip[$whole]) & $mask) === (ord($net[$whole]) & $mask);
}
```

The namespace keeps `in_range` from colliding with a function of the same name in another plugin, which in WordPress would be a fatal error. We tested `from_cloudflare` at both edges of six of the ranges, IPv4 and IPv6, and just outside them, and it was right every time.

Through the stand-in, we sent the test app what Cloudflare would forward for a visitor on the Mullvad server who set their own `Client-IP` to a home broadband address. Then the same visitor skipped Cloudflare and connected to the server's own address, with `CF-Connecting-IP` set to the home address:

| Code | Through Cloudflare | `is_vpn` | Straight to the server | `is_vpn` |
|---|---|---|---|---|
| `REMOTE_ADDR` | `173.245.48.10` | `false` | `23.165.56.3` | `true` |
| `get_client_ip()` | the home address | `false` | the home address | `false` |
| `HTTP_CF_CONNECTING_IP ?? REMOTE_ADDR` | `23.165.56.3` | `true` | the home address | `false` |
| `client_ip()` | `23.165.56.3` | `true` | `23.165.56.3` | `true` |

Reading `CF-Connecting-IP` whenever it's there fixes the first path and opens the second: anyone who finds your server's own address can skip Cloudflare and write the header themselves. `client_ip()` takes it only from Cloudflare's addresses. Behind your own nginx or load balancer, the rule is the same: read its header only when `REMOTE_ADDR` is that proxy.

## Check signups in plain PHP

The library caches answers, but on the client object, and every PHP request starts with a new one. [APCu](https://www.php.net/manual/en/book.apcu.php) keeps them in shared memory across requests and workers. It's an extension: `pecl install apcu`.

```php
<?php
// vpn_lookup.php
use VPNDetection\Client;
use VPNDetection\Options;
use VPNDetection\Result;
use VPNDetection\VPNDetectionException;

/** The answer for $ip, kept in APCu for an hour; null when the lookup failed. */
function vpn_lookup(string $ip): ?Result
{
    $result = apcu_fetch("vpnd:$ip", $hit);
    if ($hit) {
        return $result;
    }
    $client = new Client(new Options(
        apiKey: getenv('VPNDETECTION_API_KEY') ?: null,
        timeout: 2.5,
        retries: 0,
    ));
    try {
        $result = $client->lookup($ip);
    } catch (VPNDetectionException $err) {
        error_log("vpndetection: {$err->kind->value}: {$err->getMessage()}");
        return null;
    }
    apcu_store("vpnd:$ip", $result, 3600);
    return $result;
}
```

```php
<?php
// signup.php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/client_ip.php';
require __DIR__ . '/vpn_lookup.php';

use function VpnCheck\client_ip;

$result = vpn_lookup(client_ip());

// A VPN, residential proxy or Tor visitor confirms their email first, and so
// does anyone whose lookup failed: then you know nothing about them.
$confirm_email = $result === null
    || $result->isVpn
    || $result->isResproxy === true
    || $result->isTor === true;

header('Content-Type: application/json');
echo json_encode(['confirm_email' => $confirm_email]), "\n";
```

We sent `signup.php` four visitors through the same stand-in for Cloudflare. The residential proxy exit is from our data, and the Tor exit from the Tor Project's [exit list](https://check.torproject.org/torbulkexitlist):

| Visitor | `signup.php` answered |
|---|---|
| Mullvad server | `{"confirm_email":true}` |
| Home broadband | `{"confirm_email":false}` |
| Residential proxy exit, `hits` 23 | `{"confirm_email":true}` |
| Tor exit `171.25.193.25` | `{"confirm_email":true}` |

Three signups in a row from the Mullvad server made one API call: APCu counted one miss and two hits, and the two cached requests answered in about 1 ms, against 19 ms for the first. `isResproxy` and `isTor` are compared with `=== true` because either can be `null`, as "Absent is not false" below explains.

## Flag signups and orders in WordPress

None of this needs a plugin from WordPress's directory. A must-use plugin is a PHP file in `wp-content/mu-plugins`, which WordPress loads on every request, with no activation, and which can't be switched off from the dashboard ([WordPress's guide](https://developer.wordpress.org/advanced-administration/plugins/mu-plugins/)). It loads the PHP files directly in that folder and skips subfolders, so the library goes in a folder of its own beside the plugin:

```bash
cd wp-content/mu-plugins
mkdir vpndetection && cd vpndetection
composer require vpndetection/vpndetection
cp /path/to/client_ip.php /path/to/cloudflare-ips.txt .
```

No Composer on your host? Run the same commands on your own machine and upload the folder. Then give WordPress the key in `wp-config.php`, or put the key itself in quotes where the `getenv` call is:

```php
define('VPNDETECTION_API_KEY', getenv('VPNDETECTION_API_KEY') ?: null);
```

The plugin, as `wp-content/mu-plugins/vpn-check.php`:

```php
<?php
/**
 * Plugin Name: VPN check
 * Description: Refuses signups over Tor; flags VPN and proxy signups and orders.
 */

require_once __DIR__ . '/vpndetection/vendor/autoload.php';
require_once __DIR__ . '/vpndetection/client_ip.php';

use VPNDetection\Client;
use VPNDetection\Options;
use VPNDetection\Result;
use VPNDetection\VPNDetectionException;
use function VpnCheck\client_ip;

/** The answer for $ip, kept for an hour in a transient; null on a failed lookup. */
function vpnd_lookup(string $ip): ?Result
{
    static $failed = []; // both hooks ask: one attempt a request, even when it fails
    if (isset($failed[$ip])) {
        return null;
    }
    $result = get_transient("vpnd_$ip");
    if ($result instanceof Result) {
        return $result;
    }
    $client = new Client(new Options(
        apiKey: defined('VPNDETECTION_API_KEY') ? VPNDETECTION_API_KEY : null,
        timeout: 2.5,
        retries: 0,
    ));
    try {
        $result = $client->lookup($ip);
    } catch (VPNDetectionException $err) {
        error_log("vpndetection: {$err->kind->value}: {$err->getMessage()}");
        $failed[$ip] = true;
        return null;
    }
    set_transient("vpnd_$ip", $result, HOUR_IN_SECONDS);
    return $result;
}

// Refuse a signup over Tor.
add_filter('registration_errors', function (WP_Error $errors) {
    if (vpnd_lookup(client_ip())?->isTor) {
        $message = '<strong>Error:</strong> Please sign up without Tor.';
        $errors->add('vpnd_tor', $message);
    }
    return $errors;
});

/** What to flag about the visitor: a VPN, a residential proxy, Tor, or no answer. */
function vpnd_flags(): array
{
    $result = vpnd_lookup(client_ip());
    return array_keys(array_filter([
        'VPN' => $result?->isVpn,
        'Residential proxy' => $result?->isResproxy,
        'Tor' => $result?->isTor,
        'Not checked' => $result === null,
    ]));
}

// Flag a new account in Users.
add_action('register_new_user', function (int $user_id) {
    if ($flags = vpnd_flags()) {
        update_user_meta($user_id, 'vpnd_flags', implode(', ', $flags));
    }
});

// Show the flags in a column in Users.
add_filter('manage_users_columns', fn ($cols) => $cols + ['vpnd' => 'Network']);
add_filter('manage_users_custom_column', function ($output, $column, $user_id) {
    if ($column !== 'vpnd') {
        return $output;
    }
    return esc_html(get_user_meta($user_id, 'vpnd_flags', true));
}, 10, 3);

// Note a WooCommerce order, from the block checkout or the classic one.
function vpnd_note_order(WC_Order $order): void
{
    if ($flags = vpnd_flags()) {
        $order->add_order_note('Network: ' . implode(', ', $flags));
    }
}
add_action('woocommerce_store_api_checkout_order_processed', 'vpnd_note_order');
add_action('woocommerce_checkout_order_processed', function ($id, $data, $order) {
    vpnd_note_order($order);
}, 10, 3);
```

[`registration_errors`](https://developer.wordpress.org/reference/hooks/registration_errors/) runs before the account exists, so an error there stops the signup and shows on the form. [`register_new_user`](https://developer.wordpress.org/reference/hooks/register_new_user/) runs once WordPress has created an account from its signup form: an account we added with WP-CLI got no flag. Every new account already proves its email, since WordPress mails it the link that sets the password. So apart from Tor, the plugin holds nobody back: it marks the account, and Users shows the mark where you decide what to do with it.

WooCommerce has two checkouts, and each fires its own hook once the order exists. New stores get the block checkout, which places the order through WooCommerce's Store API and fires `woocommerce_store_api_checkout_order_processed`. The classic one, the `[woocommerce_checkout]` shortcode, fires `woocommerce_checkout_order_processed`. An order is never refused; it gets a private note, under the order's notes in wp-admin.

We signed up from each address through the stand-in for Cloudflare, then from the Mullvad server straight to the server, and once with the API made unreachable:

| Signup from | Result | Network column |
|---|---|---|
| Mullvad server | account created | VPN |
| Home broadband | account created | |
| Residential proxy exit | account created | Residential proxy |
| Tor exit | no account: "**Error:** Please sign up without Tor." | |
| Mullvad server, straight to the server, `CF-Connecting-IP` set to the home address | account created | VPN |
| Home broadband, API unreachable | account created | Not checked |

Orders went through the same stand-in, in both checkouts:

| Checkout | Order from | Note on the order |
|---|---|---|
| Block | Mullvad server | Network: VPN |
| Block | Home broadband | |
| Block | Residential proxy exit | Network: Residential proxy |
| Block | Home broadband, API unreachable | Network: Not checked |
| Classic | Mullvad server | Network: VPN |
| Classic | Tor exit | Network: Residential proxy, Tor |
| Classic | Home broadband | |

The Tor exit's note says Residential proxy too: the API saw that address once as a proxy exit, on July 3, and on the Max plan `resproxy->hits` gives that count.

A signup runs two hooks on the same address. The transient answers the second from the first, and anything else from that address in the next hour. A failed lookup isn't stored, and `$failed` keeps it to one attempt per request: before we added it, both hooks tried the API, and a slow one would have held the form twice as long. The plugin covers WordPress's own signup form and WooCommerce's checkouts; a membership plugin with a form of its own has its own hook.

## Absent is not false

```php
<?php
// absent.php
require __DIR__ . '/vendor/autoload.php';

use VPNDetection\Client;

$result = (new Client())->lookup($argv[1]);
var_dump($result->isVpn, $result->isTor, $result->tor);
```

Without a key, the Tor exit from the signup test answers:

```bash
php absent.php 171.25.193.25
```

```
bool(false)
NULL
NULL
```

Those `NULL`s aren't `false`. They mean "not in your plan", where `false` means "checked, and no". `isVpn` is on every plan, the hosting, Tor, relay and CDN flags start on Starter, the proxy flags on Scale, and `confidence`, `method` and `resproxy.hits` are on Max.

So on the free plan, the Tor and proxy checks above can never match. Without a key, `signup.php` let the same Tor exit through with `{"confirm_email":false}`, and the plugin's `?->isTor` reads `null` the same way. In your own code, `$result->isTor === false` means checked and not Tor.

## When a lookup fails

Both versions catch `VPNDetectionException`, log it, and let the signup or order through. With an invalid key, `vpn_lookup()` returned `null` and logged:

```
vpndetection: unauthorized: invalid API key
```

With the API unreachable, the WordPress signup went through flagged Not checked, and Apache's error log had a `network` error with curl's `Failed to connect to api.vpndetection.io port 443`. `kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`.

Each lookup is one attempt of at most 2.5 seconds, from `timeout: 2.5` and `retries: 0`. The library's defaults, 30 seconds an attempt and two retries, suit a batch job better than a signup form. Refusing every signup you couldn't check would make any outage of ours an outage of your signups. Fail open, and ask more of a visitor you couldn't check, as both versions do.

A free key covers 50,000 lookups a month, and paid plans start at 250,000 ([pricing](https://vpndetection.io/pricing)). Only signups and orders are looked up here, and a returning address costs nothing for an hour. To check every page view with a few million distinct visitors a day, download the [VPN IP database](https://vpndetection.io/databases/vpn-ip) and look addresses up locally instead of calling the [API](https://vpndetection.io/api).

## Next steps

On Laravel or Symfony, the [Laravel](https://docs.vpndetection.io/integrations/laravel) and [Symfony](https://docs.vpndetection.io/integrations/symfony) middleware are built on this library, with the same 2.5-second, no-retry lookup by default. The library is on [Packagist](https://packagist.org/packages/vpndetection/vpndetection) with its source on [GitHub](https://github.com/vpndetection-io/sdk-php), and every field the API answers is in the [docs](https://docs.vpndetection.io).

