Restrict WordPress Admin and Login to Your IP Address

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 on init at priority 0, before most plugins do their admin work, and calls wp_die() with status 403 when dpc_admin_ip_should_block() says so.
  • A request is protected when it is a wp-admin screen (is_admin()) or wp-login.php. AJAX, cron, command-line PHP such as WP-CLI (no REMOTE_ADDR) and admin-post.php are skipped, and the REST API never counts as admin. On wp-login.php only action=postpass is 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-For and 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 ); in wp-config.php turns the whole check off. DPC_ADMIN_ALLOWED_IPS adds IPs from wp-config.php, and the dpc_admin_ip_allowlist filter 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_IPS as a comma-separated list.
  • Login screen only: in dpc_admin_ip_is_protected_request(), replace the final return is_admin(); with return false;.
  • Server level instead of PHP: Apache 2.4 does the same with a Require ip rule for wp-login.php and wp-admin, and nginx with allow and deny in a location block. That stops requests before PHP starts, but you must exclude admin-ajax.php and admin-post.php yourself.

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_ADDR is the proxy’s address, not the visitor’s. Fix it at the server (Apache mod_remoteip, nginx real_ip_header with the proxy’s IP ranges) so REMOTE_ADDR is correct. Only use the dpc_admin_ip_client_ip filter to read CF-Connecting-IP after checking that REMOTE_ADDR is one of Cloudflare’s published ranges, otherwise anyone can fake the header.
  • For the same reason, never add 127.0.0.1 or 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_IPS or set DPC_ADMIN_IP_LOCK_OFF through 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.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.