Skip to content
iplogs.com

WordPress: block VPN users on login, registration and checkout

By Muntaha Shaheen · · 10 min read

Drop one PHP file into wp-content/mu-plugins that sends the visitor's IP to a detection API with wp_remote_post, caches the answer in a transient, and adds an error on the authenticate, registration_errors and WooCommerce checkout hooks when the verdict is vpn_detected. Read the IP from REMOTE_ADDR, and trust X-Forwarded-For only when the request comes from your own proxy.

Nobody needs to stop VPN users reading a blog post. The forms are another matter. Fake accounts arrive through registration, stolen passwords get tried against wp-login.php, and card testers push small orders through WooCommerce checkout. Each of those is a form submission, so a lookup there adds a wait to one request (about 3 seconds for an address the API hasn't seen, instant from the cache after that) instead of slowing every page.

What follows is a single must-use plugin built on core functions only. It runs with or without WooCommerce, and when the detection service is slow or down it lets the visitor through.

How do I block VPN users in WordPress without a plugin?

Hook the forms yourself. Each callback asks the same question about the visitor's IP and, for a vpn_detected answer, returns the kind of error WordPress or WooCommerce already displays on that form:

FormHookHow the block shows up
wp-login.php and WooCommerce My Account loginauthenticate filter, priority 30Return a WP_Error; the login form displays it
wp-login.php registrationregistration_errors filterAdd to the WP_Error it passes in
WooCommerce My Account registrationwoocommerce_registration_errors filterAdd to the WP_Error it passes in
WooCommerce classic (shortcode) checkoutwoocommerce_after_checkout_validation actionAdd to the $errors object; checkout shows a notice
WooCommerce Checkout blockrest_authentication_errors filter on the Store API checkout routeReturn a WP_Error with status 403

If you'd rather have a settings screen than code, the plugin directory has plugins built for this job, such as Proxy & VPN Blocker and VPN Guard. The code below is for sites where you want to read every line that runs on your login form.

Why use a must-use plugin?

A must-use plugin can't be switched off from the Plugins screen, which suits a security check. WordPress loads every PHP file placed directly in wp-content/mu-plugins, automatically and in alphabetical order, before normal plugins. Two caveats from the handbook: files in subdirectories aren't loaded, and activation hooks never run. Neither matters here.

REMOTE_ADDR or X-Forwarded-For: which IP is the visitor's?

$_SERVER['REMOTE_ADDR'] is the address of whatever opened the TCP connection to your server. A visitor can't fake it by sending a header. Behind a CDN or load balancer, though, it's the proxy's address, and the visitor's address arrives in X-Forwarded-For, a header that anybody can send with any value they like.

Read the header only when REMOTE_ADDR belongs to a proxy you run or pay for, and then take the rightmost address in the chain that isn't one of your proxies. Cloudflare, for example, appends the address that connected to it to whatever X-Forwarded-For the client already sent, so anything to the left of that entry came from the client and proves nothing.

Don't reuse WooCommerce's geolocation helper for this. WC_Geolocation::get_ip_address() returns X-Real-IP if present, otherwise the first X-Forwarded-For entry, and only then REMOTE_ADDR, without checking where the request came from. That is a reasonable guess for picking a shipping country. As a security input it lets any client choose its own IP.

The plugin reads your proxy list from a constant in wp-config.php. For a load balancer on a private network:

// wp-config.php, above "That's all, stop editing!"
define( 'IPLOGS_TRUSTED_PROXIES', array( '10.0.0.0/8' ) );

For a site proxied by Cloudflare, list Cloudflare's published ranges. These are the ranges on cloudflare.com/ips as of October 2026; check that page when you deploy, because they do change:

define( 'IPLOGS_TRUSTED_PROXIES', array(
	'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
	'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
	'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
	'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
	'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
	'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
) );

If your server already rewrites REMOTE_ADDR to the visitor's address before PHP runs, leave the constant undefined. To see what PHP actually receives, temporarily add error_log( $_SERVER['REMOTE_ADDR'] . ' ' . ( $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '-' ) ); to the plugin, load a page, and compare it with the address the iplogs.com homepage shows you.

The complete mu-plugin

Save this as wp-content/mu-plugins/iplogs-check.php. It needs PHP 7.4 or later.

<?php
/**
 * Plugin Name: iplogs VPN and proxy check
 * Description: Checks the visitor's IP on login, registration and WooCommerce checkout.
 */

defined( 'ABSPATH' ) || exit;

const IPLOGS_MESSAGE = 'Please turn off your VPN or proxy and try again.';

function iplogs_ip_in_cidr( string $ip, string $cidr ): bool {
	$parts = explode( '/', $cidr, 2 );
	$ip_b  = filter_var( $ip, FILTER_VALIDATE_IP ) ? inet_pton( $ip ) : false;
	$net_b = filter_var( $parts[0], FILTER_VALIDATE_IP ) ? inet_pton( $parts[0] ) : false;
	if ( false === $ip_b || false === $net_b || strlen( $ip_b ) !== strlen( $net_b ) ) {
		return false;
	}
	$bits  = isset( $parts[1] ) ? (int) $parts[1] : strlen( $ip_b ) * 8;
	$bytes = intdiv( $bits, 8 );
	if ( 0 !== strncmp( $ip_b, $net_b, $bytes ) ) {
		return false;
	}
	$rest = $bits % 8;
	if ( 0 === $rest ) {
		return true;
	}
	$mask = 0xff & ( 0xff << ( 8 - $rest ) );
	return ( ord( $ip_b[ $bytes ] ) & $mask ) === ( ord( $net_b[ $bytes ] ) & $mask );
}

function iplogs_is_trusted_proxy( string $ip ): bool {
	$proxies = defined( 'IPLOGS_TRUSTED_PROXIES' ) ? IPLOGS_TRUSTED_PROXIES : array();
	foreach ( $proxies as $cidr ) {
		if ( iplogs_ip_in_cidr( $ip, $cidr ) ) {
			return true;
		}
	}
	return false;
}

/** The visitor's IP, or '' when it can't be determined safely. */
function iplogs_client_ip(): string {
	$remote = (string) ( $_SERVER['REMOTE_ADDR'] ?? '' );
	if ( ! iplogs_is_trusted_proxy( $remote ) ) {
		// Direct connection: any forwarding header is the client's own claim.
		return $remote;
	}
	$chain = explode( ',', wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '' ) );
	foreach ( array_reverse( $chain ) as $hop ) {
		$hop = trim( $hop );
		if ( '' !== $hop && ! iplogs_is_trusted_proxy( $hop ) ) {
			return rest_is_ip_address( $hop ) ? $hop : '';
		}
	}
	return '';
}

/** The iplogs verdict as an array, or null when there is no answer (allow). */
function iplogs_check( string $ip ): ?array {
	if ( '' === $ip || get_transient( 'iplogs_backoff' ) ) {
		return null;
	}
	$cached = get_transient( 'iplogs_' . $ip );
	if ( is_array( $cached ) ) {
		return $cached;
	}

	$response = wp_remote_post(
		'https://iplogs.com/v1/check',
		array(
			'timeout' => 5,
			'headers' => array(
				'Content-Type' => 'application/json',
				'Accept'       => 'application/json',
			),
			'body'    => wp_json_encode( array( 'ip' => $ip ) ),
		)
	);
	if ( is_wp_error( $response ) ) {
		error_log( 'iplogs: ' . $response->get_error_message() );
		return null;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( 429 === $code ) {
		$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
		set_transient( 'iplogs_backoff', 1, $wait > 0 ? $wait : MINUTE_IN_SECONDS );
		return null;
	}
	$data = json_decode( wp_remote_retrieve_body( $response ), true );
	if ( 200 !== $code || ! is_array( $data ) ) {
		return null;
	}
	if ( 'fast' === ( $data['mode'] ?? '' ) ) {
		return $data; // in-memory answer after the daily quota: don't keep it
	}
	set_transient( 'iplogs_' . $ip, $data, 15 * MINUTE_IN_SECONDS );
	return $data;
}

function iplogs_should_block(): bool {
	$verdict = iplogs_check( iplogs_client_ip() );
	if ( ! is_array( $verdict ) ) {
		return false;
	}
	// Privacy relays (iCloud Private Relay, Cloudflare WARP) report is_relay
	// and pass, even though WARP alone scores vpn_detected.
	if ( ! empty( $verdict['is_relay'] ) ) {
		return false;
	}
	// A VPN or Tor exit, or an open or residential proxy.
	return 'vpn_detected' === ( $verdict['verdict'] ?? '' ) || ! empty( $verdict['is_proxy'] );
}

// Login. Priority 30 runs after WordPress checks the password at 20, which
// would otherwise replace our error. Returning the same error whether the
// password was right or wrong avoids telling an attacker which one it was.
add_filter(
	'authenticate',
	function ( $user, $username ) {
		if ( '' === (string) $username || ! iplogs_should_block() ) {
			return $user;
		}
		return new WP_Error( 'iplogs_blocked', IPLOGS_MESSAGE );
	},
	30,
	2
);

// Registration on wp-login.php and on the WooCommerce account page.
function iplogs_registration_errors( $errors ) {
	if ( iplogs_should_block() ) {
		$errors->add( 'iplogs_blocked', IPLOGS_MESSAGE );
	}
	return $errors;
}
add_filter( 'registration_errors', 'iplogs_registration_errors' );
add_filter( 'woocommerce_registration_errors', 'iplogs_registration_errors' );

// WooCommerce classic (shortcode) checkout.
add_action(
	'woocommerce_after_checkout_validation',
	function ( $data, $errors ) {
		if ( iplogs_should_block() ) {
			$errors->add( 'iplogs_blocked', IPLOGS_MESSAGE );
		}
	},
	10,
	2
);

// WooCommerce Checkout block, which posts to the Store API instead.
add_filter(
	'rest_authentication_errors',
	function ( $result ) {
		if ( is_wp_error( $result ) || 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
			return $result;
		}
		$route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
		if ( ! preg_match( '#/wc/store(?:/v\d+)?/checkout#', $route ) || ! iplogs_should_block() ) {
			return $result;
		}
		return new WP_Error( 'iplogs_blocked', IPLOGS_MESSAGE, array( 'status' => 403 ) );
	}
);

How the plugin calls the API and caches the answer

wp_remote_post already defaults to a 5-second timeout. The plugin sets it anyway, so the limit is visible in the code and a later change to the default doesn't move it. Network failures and timeouts come back as a WP_Error rather than an exception, which is why is_wp_error() is the first thing checked after the call.

Verdicts are cached in a transient named iplogs_<ip> for 15 minutes. get_transient() returns false for an entry that is missing or expired, so the plugin stores the whole response array and tests is_array(); a stored boolean would be ambiguous. If a memory cache plugin is installed, transients live in memory instead of the database and can disappear before they expire, which here only costs an extra lookup. An answer whose mode is fast is never cached: that is the API's in-memory answer once the daily quota is spent, and caching it would keep a thinner verdict for the next 15 minutes.

A 429 means the site has gone past 60 requests a minute. The plugin reads Retry-After, sets an iplogs_backoff transient for that many seconds (a minute if the header is missing) and makes no calls until it's gone. That path, like every other failure, returns null, and null lets the visitor through: trouble on the detection side leaves a few signups unchecked but never breaks checkout.

Why the login hook runs at priority 30

WordPress registers its own username, email and application password checks on authenticate at priority 20. When a username and password were submitted, wp_authenticate_username_password() looks the user up and returns either a WP_User or a fresh WP_Error, discarding whatever an earlier callback returned. An error added at priority 10 would be overwritten.

Running at 30 means the password has already been checked. The plugin then replaces the result with the same VPN error whether the password was right or wrong. If it only blocked successful logins, an attacker on a VPN could tell valid passwords from invalid ones by which message came back.

How do I block VPNs on WooCommerce checkout?

Hook both checkouts, because WooCommerce has two and they don't share validation hooks. The classic shortcode checkout fires woocommerce_after_checkout_validation with the posted data and a WP_Error; anything added to it shows as a notice and stops the order.

The Checkout block submits to the Store API at /wc/store/v1/checkout instead, and WooCommerce's docs list classic validation hooks such as woocommerce_checkout_process as only partly supported there. Their own tutorial for fraud and CAPTCHA plugins validates the request in the core rest_authentication_errors filter, matching the checkout route with the same regular expression used above. Returning a WP_Error stops the order; the 'status' => 403 in its data makes the REST response a 403 rather than a generic error. Pass through any error another plugin already returned rather than replacing it.

Which visitors should the plugin block?

vpn_detected, the band where the score is 0.85 or higher, which covers published VPN servers and Tor exits, plus any address the API flags is_proxy (open proxies and residential-proxy exits). Privacy relays pass either way: the API sets is_relay for iCloud Private Relay and Cloudflare WARP, and a WARP address on its own scores 0.85, so without that check WARP users would be refused. The two bands below vpn_detected, vpn_likely (0.60 to 0.85) and suspicious (0.30 to 0.60), catch plenty of ordinary customers. Safari users with iCloud Private Relay turned on end up there, as do staff ordering from behind a company VPN and people sharing an address on a mobile network. For those orders a manual review or an email check is a better answer than a refusal.

A clean answer is weaker evidence than it sounds. An exit on a residential proxy network is almost impossible to tell from a home connection by its IP address, so someone ordering through one can pass this check; keep whatever other fraud screening the shop already runs.

How do I test the plugin?

WP-CLI's wp eval loads WordPress, including mu-plugins, and runs a line of PHP. Check a published Tor exit relay, then clear the cached answer:

$ wp eval 'var_dump( iplogs_check( "192.42.116.44" )["verdict"] ?? null );'
string(12) "vpn_detected"

$ wp transient delete iplogs_192.42.116.44

That address was a Tor exit on 27 October 2026, with a score of 1 and ip_info.vpn_provider set to Tor. Exit relays change, so pick a current one from the Tor exit node list if it has gone. For the forms themselves, use a staging copy and a VPN app on your own device: register, log in with a wrong password and a right one, and place a test order through whichever checkout your shop uses. Each should be refused.

Because the hooks run when a form is submitted, they keep working when a page cache serves the pages themselves.

Will a busy WooCommerce shop hit the free limits?

It can, because every lookup leaves from your web server's address and the whole site shares one allowance: 60 requests a minute and 2,000 full checks per UTC day. Going over the daily figure doesn't break checkout. The API keeps returning HTTP 200 in fast mode, which skips the live probes, and no key or sign-up is involved at any point. The transient keeps the count down, too: a shopper who retries a declined card five times costs one call. On a site with heavy login traffic, 'mode' => 'fast' in the request body for the login check alone gets an answer in microseconds from in-memory data. Store those answers under their own transient key so a fast verdict is never reused at checkout.

Questions people also ask

Can WordPress detect VPN users on its own?

Core can't. It records the connecting address in REMOTE_ADDR and has no way to tell whether that address is a VPN server, an open proxy or a Tor exit, so the answer has to come from an IP intelligence service or dataset.

Will checking IPs slow down my WordPress site?

Not page views: the checks only run when someone submits the login, registration or checkout form. On those, the first check of an address usually takes about 3 seconds while the API probes it; the plugin gives up after 5 seconds and lets the visitor through, and repeats come from the transient instantly.

Why does the plugin see the same IP for every visitor?

REMOTE_ADDR holds your CDN's or load balancer's address. List those ranges in IPLOGS_TRUSTED_PROXIES and the plugin will take the visitor's address from X-Forwarded-For instead.

Does this work with the WooCommerce Checkout block?

Yes. The block posts to the Store API, so the plugin checks it in the rest_authentication_errors filter, the approach WooCommerce documents for fraud and CAPTCHA integrations. The classic shortcode checkout is covered separately by woocommerce_after_checkout_validation.

Should I block every VPN user from my store?

No. Plenty of VPN and Private Relay users are paying customers. Refuse the vpn_detected band and flagged proxies at checkout and registration, and route the grey cases to manual review or email verification.

Sources

  1. WordPress Advanced Administration Handbook: Must-Use Plugins
  2. WordPress Developer Reference: WP_Http::request() arguments
  3. WordPress Common APIs Handbook: Transients
  4. WordPress Developer Reference: authenticate hook
  5. WordPress Developer Reference: registration_errors hook
  6. WordPress source: default-filters.php (authenticate priorities)
  7. WordPress source: user.php (wp_authenticate_username_password)
  8. WooCommerce source: class-wc-checkout.php (woocommerce_after_checkout_validation)
  9. WooCommerce source: class-wc-geolocation.php (get_ip_address)
  10. WooCommerce developer docs: Integrating protection with the Checkout block
  11. WooCommerce developer docs: Hook alternatives
  12. Cloudflare docs: Cloudflare HTTP headers
  13. Cloudflare IP ranges
  14. WP-CLI: wp eval
  15. WordPress plugin directory: Proxy & VPN Blocker
  16. WordPress plugin directory: VPN Guard