Skip to main content
WordPress made easy with the drag & drop Total WordPress Theme!Learn More

Prevent User Enumeration in WordPress without a Plugin

Last modified: November 5, 2023

User Enumeration is a website attack where the attacker tries to scan your site for usernames which it can then use to brute force into your site. Basically it’s a method used to try and locate a username which it will then use to “guess” it’s password and gain access to your site.

Unfortunately in WordPress usernames are exposed to the public via a few methods so it’s a good idea to hide these to keep your site more secure. There are already great plugins out there you can use to prevent user enumeration in WordPress. One is the “Stop Enumeration” plugin (which I highly recommend you download if at all just to read through the code and see how it works), but perhaps you are a plugin skeptic and don’t trust them so you rather use your own code.

Important: I am not a website security expert and using the suggested code may not fully protect your site. This article is intended purely for educational purposes only.

Prevent Access to Author Archives via user ID’s

By default WordPress creates user archives in the format of yoursite.com?author=ID which means attackers can run a request to the ?author={int} URL over and over with different numbers until they get a match so they know it’s a valid author. So we want to hide these URL’s completely (let’s be honest they don’t really serve a purpose anyway).

add_action( 'init', function() {
	if ( isset( $_REQUEST['author'] )
		&& preg_match( '/\\d/', $_REQUEST['author'] ) > 0
		&& ! is_user_logged_in()
	) {
		wp_die( 'forbidden - number in author name not allowed = ' . esc_html( $_REQUEST['author'] ) );
	}
} );

Even if your permalink settings are not set to the default (aka “Plain) WordPress will redirect the ID based author URL’s to the correct author page if one exists. So you will still want to prevent this.

Prevent Access to Users in the REST API

The WordPress REST API (which we love) also exposes users in the frontend. If it’s currently enabled on your site (it is by default but some hosting companies/security plugins disable it) you should be able to visit your site at yoursite.com/wp-json/wp/v2/users/ and view a list of all your users with their ID’s, name, url, description and other information.

The following code can be used to disable the users REST API endpoint for non-logged in users:

add_action( 'rest_authentication_errors', function( $access ) {
	if ( is_user_logged_in() ) {
		return $access;
	}

	if ( ( preg_match( '/users/i', $_SERVER['REQUEST_URI'] ) !== 0 )
		|| ( isset( $_REQUEST['rest_route'] ) && ( preg_match( '/users/i', $_REQUEST['rest_route'] ) !== 0 ) )
	) {
		return new \WP_Error(
			'rest_cannot_access',
			'Only authenticated users can access the User endpoint REST API.',
			[
				'status' => rest_authorization_required_code()
			]
		);
	}

	return $access;
} );

Remove Users from the WordPress Sitemap

The default WordPress sitemap located at yoursite.com/wp-sitemap.xml contains a sitemap that goes to all the author pages on your site which you may want to remove as well. The following snippet removes the user archives from the WordPress sitemap:

add_filter( 'wp_sitemaps_add_provider', function( $provider, $name  ) {
	if ( 'users' === $name ) {
		return false;
	}

	return $provider;
}, 10, 2 );

Remove the Author URL’s from Embeds

WordPress has a built-in function named “oEmbed” which allows you to embed content from other sites on your site but also allows you to display posts from your site on other WordPress sites. When another site makes a request to embed a page from your site it makes a request to your site that looks something like this: yoursite.com/wp-json/oembed/1.0/embed?url={page-to-embed}.

WordPress then returns data associated with the requested page which the page making the request then uses to display the embedded page on their site

Included in the data returned by the oembed request is the author name and url. You may want to hide this data as well since an attacker can make requests to the oembed URL to gather user infomation.

add_filter( 'remove_author_from_oembed', function( $data  ) {
	unset( $data['author_url'] );
	unset( $data['author_name'] );

	return $data;
}, 10, 2 );

Disable Author Archives Completely

Author archives are not necessary for most sites, especially if your site only has a single author having them could work against your SEO efforts. We can disable the author archives by redirecting any requests to an author archive to your site homepage.

add_filter( 'template_redirect', function() {
	if ( is_author() || isset( $_GET['author'] ) ) {
		wp_safe_redirect( esc_url( home_url( '/' ) ), 301 );
	}
} );

If you prefer you could instead return a 404 error page instead of redirecting the user to the homepage. Example:

add_filter( 'template_redirect', function() {
	if ( is_author() || isset( $_GET['author'] ) ) {
		global $wp_query;
		$wp_query->set_404();
		status_header( 404 );
		nocache_headers();
	}
} );

Removing Author URLs from Themes

In the previous section I showed you how to disable the author archives, however, it’s very common for WordPress themes to link to author archives from post entries, single posts and in author bios. Which means if you remove the author archives you will now have a bunch of links either redirecting to the homepage or going to a 404 page depending on the code you used.

Now, as long as the theme you are using is coded using the core get_the_author_link function then removing the links is very simple. The following snippet overrides the get_the_author_link function to return only the author’s name and not a full link to the author’s archive.

add_filter( 'the_author_posts_link', function( $link ) {
	if ( ! is_admin() ) {
		return get_the_author();
	}
	return $link;
} );

In this code we are replacing the value of the get_the_author_posts_link() function with the value of get_the_author() so that it only returns a string and not a URL. Make sure your authors have display names that are not the same as their usernames to make it harder for attackers to guess the author’s username.

Keep in mind that theme’s can be coded however they want and they don’t all behave the same way. So there may be instances where the author’s link is still being added on the front-end. If you are not the developer of your theme you should contact the developer of the theme you are using and ask them how to properly remove all author links in the theme.

Modify the Login Form Error Notice

When using the WordPress login form if you enter an incorrect username it will display a warning that the user is not registered on the site and if you enter a correct username it will inform you that the password is incorrect. This means that when using trial and error to brute force your way into a site you will know right away if the username you are attempting to login with is a valid username for the website.

To make it harder for an attacker we don’t want them to know wheter a given username exists or does not exist on the site and we can do this by modifying the login error message.

add_filter( 'login_errors', function() {
	return 'An error occurred. Try again or if you are a bot, please don\'t.';
} );

This snippet will override all errors, so if you are using some sort of captcha plugin and the user failed the captcha it won’t let them know. You may want to modify the code to only show errors when it’s an invalid username or incorrect password. You can refer to the codex to see how the login_errors filter works.

Final Code – Full PHP Class with all Snippets Combined

Here is a full PHP class you can use with all the snippets above already added that you can paste into your site or use to create a simple plugin if you prefer. If you have any issues or questions be sure to let me know in the comments below!

class WPExplorer_Prevent_User_Enumeration {

	/**
	 * Static-only class.
	 */
	private function __construct() {}

	/**
	 * Init.
	 */
	public static function init() {
		add_filter( 'login_errors', [ self::class, 'modify_login_errors' ] );
		add_action( 'init', [ self::class, 'prevent_author_requests' ] );
		add_action( 'rest_authentication_errors', [ self::class, 'only_allow_logged_in_rest_access_to_users' ] );
		add_filter( 'wp_sitemaps_add_provider', [ self::class, 'remove_authors_from_sitemap' ], 10, 2 );
		add_filter( 'oembed_response_data', [ self::class, 'remove_author_from_oembed' ] );
		add_action( 'template_redirect', [ self::class, 'redirect_author_archives' ] );
		add_filter( 'the_author_posts_link', [ self::class, 'modify_the_author_posts_link' ] );
	}

	/**
	 * Check request.
	 */
	public static function modify_login_errors() {
		return 'An error occurred. Try again or if you are a bot, please don\'t.';
	}

	/**
	 * Check request.
	 */
	public static function prevent_author_requests() {
		if ( isset( $_REQUEST['author'] )
			&& self::string_contains_numbers( $_REQUEST['author'] )
			&& ! is_user_logged_in()
		) {
			wp_die( 'forbidden - number in author name not allowed = ' . esc_html( $_REQUEST['author'] ) );
		}
	}

	/**
	 * Only allow logged in access to users in rest API.
	 */
	public static function only_allow_logged_in_rest_access_to_users( $access ) {
		if ( is_user_logged_in() ) {
			return $access;
		}

		if ( ( preg_match( '/users/i', $_SERVER['REQUEST_URI'] ) !== 0 )
			|| ( isset( $_REQUEST['rest_route'] ) && ( preg_match( '/users/i', $_REQUEST['rest_route'] ) !== 0 ) )
		) {
			return new \WP_Error(
				'rest_cannot_access',
				'Only authenticated users can access the User endpoint REST API.',
				[
					'status' => rest_authorization_required_code()
				]
			);
		}

		return $access;
	}

	/**
	 * Returns true if string contains numbers.
	 */
	private static function string_contains_numbers( $string ): bool {
		return preg_match( '/\\d/', $string ) > 0;
	}

	/**
	 * Remove authors from sitemap.
	 */
	public static function remove_authors_from_sitemap( $provider, $name ) {
		if ( 'users' === $name ) {
			return false;
		}

		return $provider;
	}

	/**
	 * Remove authors from sitemap.
	 */
	public static function remove_author_from_oembed( $data ) {
		unset( $data['author_url'] );
		unset( $data['author_name'] );

		return $data;
	}

	/**
	 * Redirects the author archives to the site homepage.
	 */
	public static function redirect_author_archives() {
		if ( is_author() || isset( $_GET['author'] ) ) {
			wp_safe_redirect( esc_url( home_url( '/' ) ), 301 );
		}
	}

	/**
	 * Modify the authors post link.
	 */
	public static function modify_the_author_posts_link( $link ) {
		if ( ! is_admin() ) {
			return get_the_author();
		}
		return $link;
	}

}

Front_End::init();

Extra Tips to Prevent User Enumeration in WordPress

Here are some extra tips to help prevent user enumeration attacks on your WordPress site:

  • Make sure your user Display Names are not the same as their usernames. Since your author names may display on the front-end we don’t want people trying to guess the usernames.
  • Configure your web server to block requests to /?author=<number> (in exchange to using the PHP method proposed above).
  • Disable WordPress REST API completely if you aren’t using it. If you aren’t sure if you are using it then perhaps keep it enabled as it is used by Gutenberg, block themes and certain plugins. But perhaps you can disable it for all unauthenticated users.
  • Disable WordPress XML-RPC – this is not needed for most WordPress sites. A lot of the popular WordPress hosting companies make it easy to disable this via their dashboard. And I mention this here because it’s a very important thing to disable and it should be done at the server level. I recommend looking at your hosting companies documentation or if they don’t have any guides on how to disable xlm-rpc send them a support request and ask them. Personally we love and recommend CloudFlare and if you are using them you may not need to do anything, but you can always add an extra rule in your WAF rules for this.

Comments

No comments yet. Why don't you kick off the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *

Learn how your comment data is processed by viewing our privacy policy here.