WordPress Security Snippet Generator
Generate the WordPress hardening snippets worth having: file editing off, XML-RPC and REST user enumeration closed, login errors quietened, version hidden.
<?php
/**
* Hardening snippets.
*
* None of this replaces updates, strong passwords and two-factor. It closes
* the doors that are cheap to close.
*/
/*
* Put this in wp-config.php instead if you can: a constant there cannot be
* changed by a plugin, and this file can.
*/
if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) {
define( 'DISALLOW_FILE_EDIT', true );
}
// Check Jetpack and any mobile app first: both use XML-RPC.
add_filter( 'xmlrpc_enabled', '__return_false' );
/**
* Stops /?author=1 redirecting to an author archive, which is how a username
* list is collected before a brute force run.
*/
function my_site_block_author_enumeration() {
if ( is_admin() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading a public query var.
if ( ! empty( $_GET['author'] ) && ! is_user_logged_in() ) {
wp_safe_redirect( home_url(), 301 );
exit;
}
}
add_action( 'template_redirect', 'my_site_block_author_enumeration' );
/**
* Closes the REST users route to logged-out requests.
*
* /wp-json/wp/v2/users lists every author, with their slug, to anybody.
*
* @param mixed $result Current result.
* @param WP_REST_Server $server The server.
* @param WP_REST_Request $request The request.
* @return mixed
*/
function my_site_restrict_rest_users( $result, $server, $request ) {
if ( ! is_null( $result ) ) {
return $result;
}
if ( 0 !== strpos( $request->get_route(), '/wp/v2/users' ) ) {
return $result;
}
if ( is_user_logged_in() ) {
return $result;
}
return new WP_Error(
'rest_forbidden',
__( 'Authentication required.', 'my-site' ),
array( 'status' => 401 )
);
}
add_filter( 'rest_pre_dispatch', 'my_site_restrict_rest_users', 10, 3 );
/**
* Replaces the login error with one that does not say whether the username
* exists.
*
* @return string
*/
function my_site_login_error() {
return __( 'Those details are not correct.', 'my-site' );
}
add_filter( 'login_errors', 'my_site_login_error' );
/**
* Removes the generator tag and the version from asset URLs.
*
* Not real protection, since the version is detectable other ways, but it
* quietens automated scanning.
*
* @param string $src Asset URL.
* @return string
*/
function my_site_remove_version( $src ) {
if ( strpos( $src, 'ver=' ) ) {
$src = remove_query_arg( 'ver', $src );
}
return $src;
}
add_filter( 'script_loader_src', 'my_site_remove_version', 9999 );
add_filter( 'style_loader_src', 'my_site_remove_version', 9999 );
remove_action( 'wp_head', 'wp_generator' );
add_filter( 'the_generator', '__return_empty_string' );
Output is valid and updates as you type.
Fix the highlighted fields to update the output.
The WordPress hardening snippets that are worth having, with the caveats that usually go unsaid. None of this replaces updates, strong passwords and two-factor; it closes the doors that are cheap to close.
How to use
- Start with the file editors. Turning them off removes the shortest path from a stolen admin session to arbitrary code running on your server.
- Check what uses XML-RPC before you turn it off. Jetpack and the WordPress mobile apps both need it. Removing only the pingback methods is the middle ground.
- Close author enumeration and the REST users route together. Either one alone still leaks the username list.
- Treat the login limiter as friction, not protection. A transient keyed on the IP does nothing against a distributed attack and can lock out a whole office behind one address.
- Put
DISALLOW_FILE_EDITinwp-config.phprather than here, where a plugin could change it back.
Example
The REST users route, which is open to anyone by default:
function acme_restrict_rest_users( $result, $server, $request ) {
if ( ! is_null( $result ) || 0 !== strpos( $request->get_route(), '/wp/v2/users' ) ) {
return $result;
}
if ( is_user_logged_in() ) {
return $result;
}
return new WP_Error( 'rest_forbidden', 'Authentication required.', array( 'status' => 401 ) );
}
add_filter( 'rest_pre_dispatch', 'acme_restrict_rest_users', 10, 3 );
Returning early when $result is already set matters: another plugin may have answered the request, and overwriting that answer breaks it.
Pitfalls
- Turning off XML-RPC breaks Jetpack, the WordPress mobile apps and some publishing tools. Removing just the pingback methods keeps them working.
- The REST users route also backs the author dropdown in the block editor, so restrict it to logged-out requests only, not to everyone.
- A login limiter keyed on
REMOTE_ADDRblocks an entire office behind one address and does nothing to an attacker with a thousand addresses. - Trusting
X-Forwarded-Forwithout knowing your proxy is worse than ignoring it: anyone can send that header. - Hiding the version number stops nothing determined. Scanners fingerprint assets and behaviour instead.
- Generic login errors also hide genuine mistakes from your own users, who then contact support instead of noticing a typo in their email.
- Blocking
?author=does not stop enumeration through the sitemap, which lists author archives by default on WordPress 5.5 and later. - Snippets in a theme disappear when the theme changes. A site specific plugin survives.
Compatibility
DISALLOW_FILE_EDIT has worked since WordPress 3.0, xmlrpc_enabled since 3.5, rest_pre_dispatch since 4.4, and login_errors since 2.7. The REST users route exists from 4.7. wp_login_failed and authenticate have been stable throughout. The generated code targets PHP 7.0 and up, and the tool runs entirely in your browser.
Frequently asked questions
Which of these actually matters?
Will turning off XML-RPC break anything?
Is hiding the WordPress version useful?
Is the login limiter enough?
Where should this code live?
wp-config.php for the constants. Not functions.php, which goes away with the theme.