Restricting WordPress admin access to your own IP address stops password guessing on wp-login.php and keeps everyone else out of wp-admin, even with a stolen password. This snippet does it in PHP: only IP addresses on an allowlist can open the dashboard or the login screen, everyone else gets a plain 403.
Front-end features that live under wp-admin keep working for visitors: admin-ajax.php, admin-post.php, the REST API, WP-Cron and the password form of protected posts. A wp-config.php constant switches the lock off, so you can always get back in.
The snippet
<?php
/**
* Limit wp-admin and wp-login.php to an allowlist of IP addresses.
*
* Emergency off switch, in wp-config.php:
* define( 'DPC_ADMIN_IP_LOCK_OFF', true );
*
* Extra IPs without editing this code, in wp-config.php:
* define( 'DPC_ADMIN_ALLOWED_IPS', '203.0.113.10, 2001:db8::5' );
*/
// ===== Config: the IP addresses that may open wp-admin and wp-login.php =====
function dpc_admin_ip_allowlist() {
$ips = array(
'203.0.113.10', // Replace with your own public IP address.
'2001:db8::5', // IPv6 works too. Delete lines you don't need.
);
// ===== End of config =====
if ( defined( 'DPC_ADMIN_ALLOWED_IPS' ) ) {
$ips = array_merge( $ips, explode( ',', (string) DPC_ADMIN_ALLOWED_IPS ) );
}
return (array) apply_filters( 'dpc_admin_ip_allowlist', $ips );
}
add_action( 'init', 'dpc_admin_ip_lockdown', 0 );
function dpc_admin_ip_lockdown() {
if ( ! dpc_admin_ip_should_block() ) {
return;
}
wp_die(
esc_html__( 'Access to this area is restricted.', 'dpc' ),
esc_html__( 'Forbidden', 'dpc' ),
array( 'response' => 403 )
);
}
/**
* True when the current request targets wp-admin or wp-login.php
* and does not come from an allowed IP address.
*/
function dpc_admin_ip_should_block() {
if ( defined( 'DPC_ADMIN_IP_LOCK_OFF' ) && DPC_ADMIN_IP_LOCK_OFF ) {
return false;
}
if ( ! dpc_admin_ip_is_protected_request() ) {
return false;
}
// Until a real address is set, the lock does nothing, so importing the snippet can't lock you out.
if ( ! dpc_admin_ip_is_configured() ) {
return false;
}
return ! dpc_admin_ip_is_allowed( dpc_admin_ip_client_ip() );
}
/**
* True once the allowlist holds at least one address outside the documentation ranges
* used in the example (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32).
*/
function dpc_admin_ip_is_configured() {
foreach ( dpc_admin_ip_allowlist() as $ip ) {
$packed = @inet_pton( trim( (string) $ip ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( false === $packed ) {
continue;
}
$example = 4 === strlen( $packed )
? in_array( substr( $packed, 0, 3 ), array( "\xC0\x00\x02", "\xC6\x33\x64", "\xCB\x00\x71" ), true )
: 0 === strncmp( $packed, "\x20\x01\x0D\xB8", 4 );
if ( ! $example ) {
return true;
}
}
return false;
}
function dpc_admin_ip_is_protected_request() {
global $pagenow;
// No REMOTE_ADDR means command-line PHP (WP-CLI, a system cron job); web servers always set it.
if ( ! isset( $_SERVER['REMOTE_ADDR'] ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return false;
}
if ( wp_doing_ajax() || wp_doing_cron() ) {
return false;
}
// Front-end forms post to admin-post.php; visitors must reach it.
if ( 'admin-post.php' === $pagenow ) {
return false;
}
if ( 'wp-login.php' === $pagenow ) {
// Visitors unlock password-protected posts through wp-login.php?action=postpass.
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
return 'postpass' !== $action;
}
return is_admin();
}
/**
* The client IP. REMOTE_ADDR only: headers such as X-Forwarded-For are sent
* by the client and can be faked. Behind a proxy, see the dpc_admin_ip_client_ip filter.
*/
function dpc_admin_ip_client_ip() {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? trim( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$ip = (string) apply_filters( 'dpc_admin_ip_client_ip', $ip );
return false !== filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
}
function dpc_admin_ip_is_allowed( $ip ) {
$packed = '' !== $ip ? inet_pton( $ip ) : false;
if ( false === $packed ) {
return false;
}
foreach ( dpc_admin_ip_allowlist() as $allowed ) {
$allowed = trim( (string) $allowed );
// inet_pton() makes '2001:db8::5' and '2001:0db8:0:0:0:0:0:5' compare equal.
if ( false !== filter_var( $allowed, FILTER_VALIDATE_IP ) && inet_pton( $allowed ) === $packed ) {
return true;
}
}
return false;
}
How it works
dpc_admin_ip_lockdown()runs oninitat priority 0, before most plugins do their admin work, and callswp_die()with status 403 whendpc_admin_ip_should_block()says so.- A request is protected when it is a wp-admin screen (
is_admin()) orwp-login.php. AJAX, cron, command-line PHP such as WP-CLI (noREMOTE_ADDR) andadmin-post.phpare skipped, and the REST API never counts as admin. Onwp-login.phponlyaction=postpassis let through, because visitors submit post passwords there. - The client IP is
REMOTE_ADDR, the address of the machine that opened the connection.X-Forwarded-Forand similar headers are ignored, because any client can send them with any value. - IPs are compared with
inet_pton(), so IPv6 addresses match no matter how they are written. An empty or invalid address is blocked. - Escape hatch:
define( 'DPC_ADMIN_IP_LOCK_OFF', true );inwp-config.phpturns the whole check off.DPC_ADMIN_ALLOWED_IPSadds IPs fromwp-config.php, and thedpc_admin_ip_allowlistfilter adds them from code.
The older version of this post was an Apache 2.2 .htaccess file with invalid syntax, and it also blocked admin-ajax.php, which breaks front-end forms and carts for every visitor.
Add it with Scripts Organizer
In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty. Everywhere is required: wp-login.php is not a wp-admin request, so Admin only would leave the login screen open, and Conditions code only runs on front-end pages.
Put your own IP in the config array before you enable the block (search “what is my IP” from the network you work on). The same code also works unchanged in an mu-plugin, which is the safer home for it: an mu-plugin cannot be switched off from wp-admin by someone who gets in.
Customise it
- Office plus home: add both addresses to the config array, or to
DPC_ADMIN_ALLOWED_IPSas a comma-separated list. - Login screen only: in
dpc_admin_ip_is_protected_request(), replace the finalreturn is_admin();withreturn false;. - Server level instead of PHP: Apache 2.4 does the same with a
Require iprule forwp-login.phpandwp-admin, and nginx withallowanddenyin alocationblock. That stops requests before PHP starts, but you must excludeadmin-ajax.phpandadmin-post.phpyourself.
Gotchas
- Nothing is locked until you replace the example addresses (they come from ranges reserved for documentation), so importing the snippet can’t lock you out. Add your own IP, then check from another network that wp-admin is closed.
- Behind Cloudflare, a load balancer or a reverse proxy,
REMOTE_ADDRis the proxy’s address, not the visitor’s. Fix it at the server (Apachemod_remoteip, nginxreal_ip_headerwith the proxy’s IP ranges) soREMOTE_ADDRis correct. Only use thedpc_admin_ip_client_ipfilter to readCF-Connecting-IPafter checking thatREMOTE_ADDRis one of Cloudflare’s published ranges, otherwise anyone can fake the header. - For the same reason, never add
127.0.0.1or your proxy’s address to the list when a proxy forwards visitors: every visitor would then look like that address and get in. - Home and mobile IPs change. When yours does, add the new one to
DPC_ADMIN_ALLOWED_IPSor setDPC_ADMIN_IP_LOCK_OFFthrough SFTP or your host’s file manager, log in, and update the list. - Everyone outside the allowlist also loses the lost-password and registration screens of
wp-login.php. On a shop or membership site, customers must log in through a front-end account page (WooCommerce’s My Account works), or this snippet is the wrong tool.
Pair it with WordPress Security Updates Email Alert Without a Plugin so you also hear about security releases.
In the Scripts Organizer library Import it with one click from Scripts Organizer.