IP Intelligence guide
How to Use IP Intelligence in WordPress: 10 Drop-in PHP Recipes for Security, Speed, and Compliance
WordPress runs roughly 43% of the public web, which makes it the single most-attacked CMS on the planet. The good news: most of that abuse — credential stuffing, comment spam, scraper traffic, fake signups, payment fraud — comes from a surprisingly small set of hosting ASNs and known proxy ranges. One PHP helper plus the IP Intelligence API is enough to push almost all of it out before WordPress spends a single MySQL query on it.
This guide is ten production-ready recipes built on a single helper function. Each recipe is between ten and thirty lines of PHP, transient-cached so it costs at most one HTTP call per IP per day, and fail-open so the site never goes down because of an upstream issue. Drop the helper into wp-content/mu-plugins/dnschkr-ip.php and the recipes activate as soon as the matching plugin (WooCommerce, WPML, MemberPress) is present.
Jump to a recipe
- Brute-force defense for wp-login.php
- WooCommerce checkout fraud scoring
- Comment and contact-form spam scoring
- GDPR cookie banner — only load in the EU
- Auto-currency, VAT, and shipping in WooCommerce
- Smart language redirect (WPML / Polylang)
- Block hosting ASNs from search and admin-ajax
- Membership signup and affiliate fraud
- Geofencing for sanctions and gambling compliance
- Enrich users and orders with country, ASN, and threat score
Foundation
The single helper every recipe uses
Save the snippet below as wp-content/mu-plugins/dnschkr-ip.php. The mu-plugins directory loads before normal plugins, survives every theme switch, and never appears on the WordPress "Plugins" admin screen — perfect for a small infrastructure helper. Define DNSCHKR_API_KEY in wp-config.php, not here.
// wp-content/mu-plugins/dnschkr-ip.php
//
// One-file installer. Drop it in mu-plugins and every recipe below works.
// Define DNSCHKR_API_KEY in wp-config.php — never in source-controlled files.
//
// If the site is behind Cloudflare, a load balancer, or any reverse proxy
// you also want to add: define( 'DNSCHKR_TRUST_PROXY', true ); — see the
// "Trusted proxies" section below for what that turns on.
defined( 'ABSPATH' ) || exit;
/**
* Reject private, reserved, and loopback IPs. We never want to call the API
* for 127.0.0.1 (WP-Cron loopback), 10.0.0.0/8, 192.168.x, fe80::, etc.
*/
function dnschkr_is_public_ip( ?string $ip ): bool {
if ( ! $ip ) return false;
return (bool) filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
/**
* Forwarded headers (CF-Connecting-IP, X-Forwarded-For, etc.) are easy to
* spoof if PHP is reachable directly. Only honor them when the immediate
* TCP peer is something we trust — opt-in via DNSCHKR_TRUST_PROXY for
* Cloudflare / ALB / Nginx / managed-host setups, or trust loopback by
* default (covers the same-host Nginx → PHP-FPM pattern).
*/
function dnschkr_is_trusted_proxy( ?string $remote ): bool {
if ( ! $remote ) return false;
if ( defined( 'DNSCHKR_TRUST_PROXY' ) && DNSCHKR_TRUST_PROXY === true ) return true;
return in_array( $remote, [ '127.0.0.1', '::1' ], true );
}
/**
* Resolve the real client IP behind Cloudflare, ALB, Nginx, managed WP
* hosts, or none of the above. Returns null in CLI / cron / WP-CLI
* contexts where there is no client IP to look up.
*/
function dnschkr_real_ip(): ?string {
// CLI, WP-CLI, queue workers, Action Scheduler — no HTTP client.
if ( PHP_SAPI === 'cli' || defined( 'WP_CLI' ) ) return null;
// Site-controlled escape hatch for unusual hosts (Pantheon's
// X-Pantheon-Client-Ip, custom App Engine setups, etc.).
$override = apply_filters( 'dnschkr_real_ip', null );
if ( dnschkr_is_public_ip( $override ) ) return $override;
$remote = $_SERVER['REMOTE_ADDR'] ?? null;
if ( dnschkr_is_trusted_proxy( $remote ) ) {
$candidates = [
'HTTP_CF_CONNECTING_IP', // Cloudflare (also Kinsta)
'HTTP_TRUE_CLIENT_IP', // Akamai, WP Engine
'HTTP_X_CLIENT_IP', // Pantheon
'HTTP_X_REAL_IP', // Nginx proxy_set_header
'HTTP_X_FORWARDED_FOR', // Generic; leftmost is the original client
];
foreach ( $candidates as $h ) {
if ( empty( $_SERVER[ $h ] ) ) continue;
$ip = trim( explode( ',', $_SERVER[ $h ] )[0] );
if ( dnschkr_is_public_ip( $ip ) ) return $ip;
}
}
// REMOTE_ADDR is set by PHP from the actual TCP peer. It cannot be
// spoofed by request headers, so it is always the safe fallback.
return dnschkr_is_public_ip( $remote ) ? $remote : null;
}
/**
* Look up an IP. Cached for 24h in transients. Fails open (returns null on error).
*/
function dnschkr_ip( ?string $ip = null ): ?array {
if ( ! defined( 'DNSCHKR_API_KEY' ) ) return null;
$ip = $ip ?: dnschkr_real_ip();
if ( ! $ip ) return null;
$key = 'dnschkr_ip_' . md5( $ip );
$cached = get_transient( $key );
if ( $cached !== false ) return $cached;
$res = wp_remote_get(
'https://dnschkr.com/api/v1/ip?address=' . rawurlencode( $ip ),
[
'timeout' => 1.5,
'redirection' => 0,
'headers' => [
'Authorization' => 'Bearer ' . DNSCHKR_API_KEY,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) !== 200 ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $res ), true )['data'] ?? null;
if ( $data ) set_transient( $key, $data, DAY_IN_SECONDS );
return $data;
}127.0.0.1, 10.0.0.0/8, 192.168.x, link-local IPv6) never reach the API; CLI / WP-Cron / queue contexts return null instead of leaking the loopback address; a 1.5-second hard timeout; and a null return on every error path so callers can return early without crashing.Foundation · trusted proxies
When to set DNSCHKR_TRUST_PROXY
Headers like CF-Connecting-IP, X-Forwarded-For, and True-Client-IP are easy to spoof if PHP is reachable directly. An attacker can simply send CF-Connecting-IP: 8.8.8.8 to the origin and — if the helper trusted those headers blindly — every recipe would treat them as Google's DNS resolver and let the request through. The helper avoids this by only honouring forwarded headers when the immediate TCP peer is a trusted proxy.
Default behaviour (no constant defined): forwarded headers are honoured only when REMOTE_ADDR is loopback (127.0.0.1 / ::1), which covers the same-host Nginx → PHP-FPM pattern that most traditional LEMP stacks use. Otherwise the helper falls back to REMOTE_ADDR, which PHP sets from the actual TCP connection and which cannot be spoofed.
Set the constant when the site sits behind a trusted edge — Cloudflare, AWS ALB / ELB, Kinsta, WP Engine, Pantheon, Cloudfront, or any external reverse proxy you control. Add this one line to wp-config.php:
define( 'DNSCHKR_TRUST_PROXY', true );
Unusual host? The helper exposes a dnschkr_real_ip WordPress filter as an escape hatch. Pantheon's HTTP_X_PANTHEON_CLIENT_IP, an internal corporate proxy that emits a custom header, App Engine's HTTP_X_APPENGINE_USER_IP — drop a one-liner in functions.php and the helper picks it up before any of the built-in candidates:
add_filter( 'dnschkr_real_ip', function () {
return $_SERVER['HTTP_X_PANTHEON_CLIENT_IP'] ?? null;
} );dnschkr_real_ip filter.Recipe 1 of 10
Brute-force defense for wp-login.php
wp-login.php is the most-attacked URL on most WordPress sites. The usual defence is a brute-force counter (Wordfence, Limit Login Attempts) — but the counter still costs a database write on every attempt. Hooking the IP layer in first means hosting-ASN floods, Tor exits, and known proxies never reach the form at all. Wordfence keeps doing its job for the residential traffic that gets through.
// Recipe 1 — wp-login.php brute-force defense
add_action( 'login_init', function () {
$info = dnschkr_ip();
if ( ! $info ) return; // fail open
$is_hosting = ( $info['asn']['type'] ?? '' ) === 'hosting';
$is_proxy = ! empty( $info['is_proxy'] ) || ! empty( $info['is_tor'] );
$severity = $info['recommendation']['severity'] ?? 0;
if ( $severity >= 2 || $is_hosting || $is_proxy ) {
status_header( 403 );
wp_die( 'Access denied.', 'Login blocked', [ 'response' => 403 ] );
}
} );login_init, before any authentication query. Cached IPs return in under 1ms. Fresh lookups are 1.5s max. Real visitors never hit the API after their first request of the day.Recipe 2 of 10
WooCommerce checkout fraud scoring
Datacenter IPs at WooCommerce checkout almost always mean stolen-card testing or reseller-bot harvesting. Country mismatch between the billing address and the IP country is a classic high-recall fraud signal. The recipe rejects the obvious cases and soft-flags borderline ones for the merchant's review queue — auto-declining on country mismatch alone produces too many false positives and burns real revenue.
// Recipe 2 — WooCommerce checkout fraud scoring
add_action( 'woocommerce_checkout_process', function () {
$info = dnschkr_ip();
if ( ! $info ) return;
$billing_country = strtoupper( $_POST['billing_country'] ?? '' );
$ip_country = strtoupper( $info['country']['iso_code'] ?? '' );
$is_datacenter = ! empty( $info['is_datacenter'] );
$threat_score = (int) ( $info['threat']['score'] ?? 0 );
if ( $is_datacenter ) {
wc_add_notice( 'This order cannot be processed from a hosting network.', 'error' );
}
if ( $threat_score >= 70 ) {
wc_add_notice( 'Payment requires manual review. Please contact support.', 'error' );
}
if ( $billing_country && $ip_country && $billing_country !== $ip_country ) {
// Soft-flag for review queue; do not auto-decline (false positives = lost revenue).
update_post_meta( wc_get_order_id_by_order_key( $_POST['order_key'] ?? '' ),
'_dnschkr_country_mismatch', "$ip_country -> $billing_country" );
}
} );post_meta only — no extra round-trips.Recipe 5 of 10
Auto-currency, VAT, and shipping in WooCommerce
Showing a Canadian shopper prices in USD or quoting a UK shopper in EUR is the fastest way to lose a checkout. The recipe sets the WooCommerce currency from the IP country on the very first request, persists the choice in the WC session so return visits skip the lookup, and falls back gracefully to the store default. Adding tax and shipping logic to the same hook is one extra branch.
// Recipe 5 — auto-pick currency for WooCommerce on first paint
add_filter( 'woocommerce_currency', function ( $currency ) {
if ( WC()->session && WC()->session->get( 'chosen_currency' ) ) {
return WC()->session->get( 'chosen_currency' );
}
$info = dnschkr_ip();
if ( ! $info ) return $currency;
static $map = [
'GB' => 'GBP', 'US' => 'USD', 'CA' => 'CAD', 'AU' => 'AUD',
'JP' => 'JPY', 'CH' => 'CHF', 'NZ' => 'NZD', 'IN' => 'INR',
];
$iso = strtoupper( $info['country']['iso_code'] ?? '' );
$is_eu = ! empty( $info['country']['is_eu'] );
$picked = $map[ $iso ] ?? ( $is_eu ? 'EUR' : $currency );
if ( WC()->session ) WC()->session->set( 'chosen_currency', $picked );
return $picked;
} );woocommerce_currency, which is invoked once per request and is cached in the WC session afterwards. The IP lookup cost is absorbed into the same transient as every other recipe.Recipe 6 of 10
Smart language redirect (WPML / Polylang)
WPML and Polylang both support manual language switchers, but neither does smart first-visit redirects out of the box. A Spanish visitor landing on the English home page is one click away from bouncing. The recipe redirects on the first front-page hit, persists the choice in a year-long cookie, and never touches return visits. Search-engine crawlers are unaffected because they hit specific URLs, not the front page.
// Recipe 6 — first-visit language redirect (WPML / Polylang)
add_action( 'template_redirect', function () {
if ( is_admin() || isset( $_COOKIE['lang_picked'] ) ) return;
if ( ! is_front_page() ) return;
$info = dnschkr_ip();
if ( ! $info ) return;
static $map = [ 'ES' => 'es', 'FR' => 'fr', 'DE' => 'de', 'IT' => 'it', 'PT' => 'pt' ];
$iso = strtoupper( $info['country']['iso_code'] ?? '' );
$lang = $map[ $iso ] ?? null;
if ( ! $lang ) return;
setcookie( 'lang_picked', $lang, time() + YEAR_IN_SECONDS, '/' );
wp_safe_redirect( home_url( '/' . $lang . '/' ), 302 );
exit;
} );template_redirect, before the page renders, so no wasted HTML generation. The cookie short-circuits the lookup on every subsequent request.Recipe 7 of 10
Block hosting ASNs from search and admin-ajax
The ?s= search query and admin-ajax.php are the two most expensive uncached endpoints on any WordPress site — and the two endpoints scrapers love most. Allowing search-engine crawlers (Googlebot, Bingbot) through while throttling everything else from hosting ASNs cuts CPU and database load measurably on busy sites. The service.kind field in the API response identifies legitimate crawlers by reverse-DNS plus published IP-range membership, not just user-agent string.
// Recipe 7 — block hosting ASNs from search and admin-ajax
add_action( 'init', function () {
if ( is_admin() ) return;
$is_search = isset( $_GET['s'] );
$is_ajax = wp_doing_ajax();
if ( ! $is_search && ! $is_ajax ) return;
$info = dnschkr_ip();
if ( ! $info ) return;
$is_hosting = ( $info['asn']['type'] ?? '' ) === 'hosting';
$is_crawler = ( $info['service']['kind'] ?? '' ) === 'search-crawler'; // Googlebot etc.
if ( $is_hosting && ! $is_crawler ) {
status_header( 429 );
header( 'Retry-After: 3600' );
wp_die( 'Rate limited.', 'Throttle', [ 'response' => 429 ] );
}
} );Retry-After header is a tenth of the cost of running the search query. Returning early on hosting traffic offloads work that WordPress would otherwise execute uncached, which is the biggest speed-up of any recipe in this guide.Recipe 8 of 10
Membership signup and affiliate fraud
Affiliate fraud, fake-account farms, and trial-abuse bots all rely on cheap signup throughput. Datacenter IPs are the cheapest source of that throughput. Combine an IP-type check with a per-/24 transient counter and the cost of mass signups goes from pennies to dollars per account. The pattern works equally well with core wp_create_user, MemberPress, Paid Memberships Pro, and WooCommerce account creation.
// Recipe 8 — block VPS / repeat-IP signups (works with WP, MemberPress, PMP)
add_filter( 'registration_errors', function ( $errors, $sanitized_user_login, $user_email ) {
$info = dnschkr_ip();
if ( ! $info ) return $errors;
if ( ! empty( $info['is_datacenter'] ) || ( $info['asn']['type'] ?? '' ) === 'hosting' ) {
$errors->add( 'signup_blocked', 'Signups are not accepted from hosting networks.' );
return $errors;
}
// Look for repeat signups from the same /24 in the last 24h.
$bucket = 'dnschkr_sig_' . md5( implode( '.', array_slice( explode( '.', dnschkr_real_ip() ), 0, 3 ) ) );
$count = (int) get_transient( $bucket );
if ( $count >= 3 ) {
$errors->add( 'signup_throttled', 'Too many recent signups from your network.' );
} else {
set_transient( $bucket, $count + 1, DAY_IN_SECONDS );
}
return $errors;
}, 10, 3 );registration_errors, which is the canonical signup-validation hook — no extra HTTP, no DB write on rejected signups. The /24 bucket uses a single transient row.Recipe 9 of 10
Geofencing for sanctions and gambling compliance
Sites with regulatory exposure — gambling, crypto exchange, regulated content, OFAC-listed jurisdictions — need to refuse traffic from specific countries or US states. The recipe returns HTTP 451 (RFC 7725, "unavailable for legal reasons"), which is the correct status code for legal blocks and is explicitly recognised by search engines so the page is not flagged as broken.
// Recipe 9 — sanctions / gambling-state geofence
add_action( 'send_headers', function () {
static $blocked_countries = [ 'RU', 'KP', 'IR', 'SY', 'CU' ];
static $blocked_us_states = [ 'WA', 'ID', 'NV' ]; // adjust per gambling licence
$info = dnschkr_ip();
if ( ! $info ) return;
$iso = strtoupper( $info['country']['iso_code'] ?? '' );
$state = strtoupper( $info['region']['iso_code'] ?? '' );
$blocked = in_array( $iso, $blocked_countries, true )
|| ( $iso === 'US' && in_array( $state, $blocked_us_states, true ) );
if ( $blocked ) {
status_header( 451 ); // RFC 7725 — unavailable for legal reasons
wp_die( 'This service is not available in your region.', 'Region blocked', [ 'response' => 451 ] );
}
} );send_headers before any template rendering. Cached lookups make the gate effectively free. A static fallback page can be served from edge cache for blocked regions if needed.Recipe 10 of 10
Enrich users and orders with country, ASN, and threat score
Existing user tables and order tables have an IP column but rarely a country, ASN, or threat score. The bulk endpoint accepts up to 1,000 IPs per request and returns the full enriched record for each one. A WP-CLI command pages through the user table in batches and writes the results back to user_meta. A site with 100,000 users finishes in roughly three minutes, and most repeat IPs are free because they are already cached on the DNS Checker side.
// Recipe 10 — enrich users + orders with ASN/country/threat (WP-CLI bulk)
//
// Run: wp dnschkr backfill-users
WP_CLI::add_command( 'dnschkr backfill-users', function () {
if ( ! defined( 'DNSCHKR_API_KEY' ) ) WP_CLI::error( 'Define DNSCHKR_API_KEY first.' );
global $wpdb;
$offset = 0;
$batch = 1000;
while ( true ) {
$rows = $wpdb->get_results(
"SELECT u.ID, m.meta_value AS ip
FROM {$wpdb->users} u
JOIN {$wpdb->usermeta} m ON m.user_id = u.ID AND m.meta_key = 'signup_ip'
WHERE u.ID NOT IN (
SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = 'dnschkr_country' )
LIMIT $batch OFFSET $offset"
);
if ( ! $rows ) break;
$ips = array_values( array_unique( array_filter( array_column( $rows, 'ip' ) ) ) );
$res = wp_remote_post( 'https://dnschkr.com/api/v1/ip/bulk', [
'timeout' => 8,
'headers' => [
'Authorization' => 'Bearer ' . DNSCHKR_API_KEY,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( [ 'addresses' => $ips ] ),
] );
if ( is_wp_error( $res ) ) WP_CLI::error( $res->get_error_message() );
$by_ip = [];
foreach ( json_decode( wp_remote_retrieve_body( $res ), true )['data'] ?? [] as $r ) {
$by_ip[ $r['address'] ] = $r;
}
foreach ( $rows as $row ) {
$r = $by_ip[ $row->ip ] ?? null;
if ( ! $r ) continue;
update_user_meta( $row->ID, 'dnschkr_country', $r['country']['iso_code'] ?? '' );
update_user_meta( $row->ID, 'dnschkr_asn', $r['asn']['number'] ?? '' );
update_user_meta( $row->ID, 'dnschkr_threat', $r['threat']['score'] ?? 0 );
}
WP_CLI::log( "Enriched " . count( $rows ) . " users (offset $offset)." );
$offset += $batch;
}
WP_CLI::success( 'Done.' );
} );wp_woocommerce_orders by swapping the SQL.Performance
Making it faster than no plugin at all
The whole point of running this at the IP layer is that it should make the site faster, not slower. Four patterns turn the helper from "an extra HTTP call" into "a guard that prevents expensive WordPress work":
- Transient-first. Every lookup goes through
get_transient()with a 24h TTL. On most WordPress hosts this hits Redis or Memcached transparently because the host has already swapped the transient backend. A site with even modest traffic answers 99% of lookups from cache. - Edge-block first. If the site is behind Cloudflare, port the Recipe 7 logic to a Cloudflare Worker. Hosting-ASN traffic gets a 429 before WordPress ever runs PHP — zero CPU, zero database, zero PHP-FPM worker tied up. The IP API call from the Worker is roughly 50ms p99 from any major region.
- Bulk for backfill. Never call the single-IP endpoint in a loop over a large table. The bulk endpoint takes 1,000 addresses per request, returns each one in a single round-trip, and amortises both credits and network overhead.
- Persistent object cache. If the host does not enable persistent object caching, install
redis-cacheor memcached. Transients fall back to the options table otherwise, which becomes a slow path on heavy-traffic sites.
The combined effect, on a real site running all ten recipes simultaneously, is that the IP guard adds roughly 0.4ms to a cached request and 1 HTTP call per IP per day. The work it prevents — wp-login attempts, search queries, AJAX endpoints, consent-banner JS — is dramatically more expensive than the work it adds.
Bonus
The Cloudflare Worker version of Recipe 1 and 7
Sites behind Cloudflare can move the login guard and scraper block to the edge and stop the worst traffic before WordPress is even invoked. The Worker keeps the exact same JSON contract — same fields, same severity rules — so the PHP fallback in mu-plugins still works for IPs that slip through (unproxied subdomains, direct-to-origin attacks).
// Cloudflare Worker — runs at the edge, before WordPress
export default {
async fetch(request, env) {
const url = new URL(request.url);
const ip = request.headers.get('cf-connecting-ip');
const sensitive =
url.pathname === '/wp-login.php' ||
url.pathname === '/xmlrpc.php' ||
(url.pathname === '/' && url.searchParams.has('s')) ||
url.pathname === '/wp-admin/admin-ajax.php';
if (!sensitive) return fetch(request);
const cached = await env.IPCACHE.get(ip, 'json');
const info = cached ?? await fetch(`https://dnschkr.com/api/v1/ip?address=${ip}`, {
headers: { Authorization: `Bearer ${env.DNSCHKR_API_KEY}`, Accept: 'application/json' },
}).then(r => r.json()).then(j => j.data);
if (!cached && info) {
await env.IPCACHE.put(ip, JSON.stringify(info), { expirationTtl: 86400 });
}
if (info?.asn?.type === 'hosting' && info?.service?.kind !== 'search-crawler') {
return new Response('Blocked.', { status: 403 });
}
return fetch(request);
},
};
Recipe 3 of 10
Comment and contact-form spam scoring
Spam comments and contact-form submissions cost more than they look: every one costs an Akismet API call, a database insert, and (often) a wp-cron task. Hooking the IP threat score before the comment hits the database short-circuits the worst offenders and leaves Akismet to focus on residential spam that actually needs ML. The same filter shape works for Contact Form 7, Gravity Forms, and WPForms with a one-line adapter.
// Recipe 3 — comment + contact-form spam scoring add_filter( 'preprocess_comment', function ( $comment ) { $info = dnschkr_ip(); if ( ! $info ) return $comment; $threat = (int) ( $info['threat']['score'] ?? 0 ); if ( $threat >= 80 || ! empty( $info['is_tor'] ) ) { wp_die( 'Comment rejected.', 'Spam filter', [ 'response' => 403 ] ); } if ( $threat >= 50 || ( $info['asn']['type'] ?? '' ) === 'hosting' ) { // Send straight to spam — Akismet quota saved. add_filter( 'pre_comment_approved', fn() => 'spam', 99 ); } return $comment; } );wp_insert_comment, so blocked spam never touches the comments table. Comments that survive are taggedspamat insert time, skipping the front-of-queue moderation work.