Skip to content
VPNDetection

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.

Published 7 min read

On this page
  1. Look up an address
  2. Get the client address right
  3. Check signups in plain PHP
  4. Flag signups and orders in WordPress
  5. Absent is not false
  6. When a lookup fails
  7. Next steps

This tutorial adds VPN detection to a PHP site with vpndetection/vpndetection, 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: 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:

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, 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 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. 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 echos 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:

CodeThrough Cloudflareis_vpnStraight to the serveris_vpn
REMOTE_ADDR173.245.48.10false23.165.56.3true
get_client_ip()the home addressfalsethe home addressfalse
HTTP_CF_CONNECTING_IP ?? REMOTE_ADDR23.165.56.3truethe home addressfalse
client_ip()23.165.56.3true23.165.56.3true

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 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:

Visitorsignup.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). 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 runs before the account exists, so an error there stops the signup and shows on the form. 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 fromResultNetwork column
Mullvad serveraccount createdVPN
Home broadbandaccount created
Residential proxy exitaccount createdResidential proxy
Tor exitno account: "Error: Please sign up without Tor."
Mullvad server, straight to the server, CF-Connecting-IP set to the home addressaccount createdVPN
Home broadband, API unreachableaccount createdNot checked

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

CheckoutOrder fromNote on the order
BlockMullvad serverNetwork: VPN
BlockHome broadband
BlockResidential proxy exitNetwork: Residential proxy
BlockHome broadband, API unreachableNetwork: Not checked
ClassicMullvad serverNetwork: VPN
ClassicTor exitNetwork: Residential proxy, Tor
ClassicHome 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 NULLs 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). 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 and look addresses up locally instead of calling the API.

Next steps

On Laravel or Symfony, the Laravel and Symfony middleware are built on this library, with the same 2.5-second, no-retry lookup by default. The library is on Packagist with its source on GitHub, and every field the API answers is in the docs.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.