13 extra things we do for better WordPress Security

You can find many SEO articles online that give you X tips on how to improve the security of your WordPress site. Among them you will find recommended ones like hide WP version, change database table prefix, change wp-admin folder, move wp-config or use the only right all-in-one security plugin. Some tips are obvious, some are ineffective, some are silly, and some can break a lot of things. However, this article tries to be different and pass on to you my many years of experience with little more advanced methods that have really worked for me.

Do not use the code snippets listed here without careful consideration and testing.

I have combined the tips from this article into two mu-plugins, which you can find on https://github.com/lynt-smitka/WP-Security-Enhancer. These plugins were created by compiling existing code that we use on our client’s sites. In one file you will find the functions related to hardening WP and in the other the audit logger. At the beginning of each file, you can simply choose which features or logging events should be active.

1. Better Password Hashing

WordPress by default uses password hashing with the phpass library and uses a “portable hash” for compatibility with PHP 5.4 and lower, which is based on MD5. While not completely weak, it can easily be replaced with the stronger Bcrypt. WordPress natively supports Bcrypt hashing, it just needs to be enabled (more specifically, portable hashes must be disabled – it is the second parameter of PasswordHash()).

global $wp_hasher;

if ( empty($wp_hasher) ) {
require_once( ABSPATH . WPINC . '/class-phpass.php');
$wp_hasher = new PasswordHash(12, false);
}

This code is enabled by the bcrypt_hash feature in the Enhancer MU Plugin.

This simple snippet will hash all future passwords using Bcrypt. We use it as an MU plugin when installing WP, so we have all passwords strongly hashed. To hash old passwords with Bcrypt, just reset them.

If you want to automate this, you can add the addtional snippet to the bcrypt settings, which will hash the user’s password with the configured hasher when the user logs in, if they are not using bcrypt.

add_filter( 'authenticate', function( $user, $username, $password ) {
	if ( ! $user instanceof WP_User ) {
        return $user;
    }
    $stored_hash = $user->data->user_pass;
    if ( strpos( $stored_hash, '$2a$' ) === 0 ) {
        return $user;
    }
    wp_set_password( $password, $user->ID );
    return $user;
}, 10, 2 );

This code is enabled by the bcrypt_rehash_passwords feature in the Enhancer MU Plugin.

It is easy to check what hash you are using: the user 1 uses Bcrypt ($2a$) with 2^10 (1024) rounds and the 2 uses default phpass ($P$) with 2^13 (8192) rounds.

Benchmark

To compare the efficiency of each hash, I tested them with Hashcat on my mid range RTX 4070 graphics card.

CUDA API (CUDA 12.3)
====================
* Device #1: NVIDIA GeForce RTX 4070, 11107/12281 MB, 46MCU

OpenCL API (OpenCL 3.0 CUDA 12.3.68) - Platform #1 [NVIDIA Corporation]
=======================================================================
* Device #2: NVIDIA GeForce RTX 4070, skipped

----------------------------------------------------------------
* Hash-Mode 3200 (bcrypt $2*$, Blowfish (Unix)) [Iterations: 1024]
----------------------------------------------------------------

Speed.#1.........:     2745 H/s (6.11ms) @ Accel:1 Loops:16 Thr:24 Vec:1

----------------------------------------------------------------
* Hash-Mode 3200 (bcrypt $2*$, Blowfish (Unix)) [Iterations: 4096]
----------------------------------------------------------------

Speed.#1.........:      690 H/s (6.14ms) @ Accel:1 Loops:16 Thr:24 Vec:1

-------------------------------------------
* Hash-Mode 400 (phpass) [Iterations: 8192]
-------------------------------------------

Speed.#1.........:  4286.8 kH/s (10.78ms) @ Accel:512 Loops:128 Thr:128 Vec:1

-------------------
* Hash-Mode 0 (MD5)
-------------------

Speed.#1.........: 54635.3 MH/s (56.06ms) @ Accel:512 Loops:1024 Thr:128 Vec:1

It takes 1500x (6000x with 12 rounds) more time to crack bcrypt then default phpass based on md5.

Side note: default phpass with 2^8 rounds takes 12000x more time to crack then plain MD5.

Alternative: https://github.com/roots/wp-password-bcrypt

DoS

Password hashing is a computationally quite demanding task. It can be abused for a DoS attack, where the server can be overloaded by sending very long passwords for authentication. If you think it falls into your threat landscape, you can limit their length before comparison.

add_filter('wp_authenticate_user', 'lynt_limit_password_length', 10, 2);

function lynt_limit_password_length($user, $password)
{
    $max_length = 150;
    if (strlen($password) > $max_length) {
        $username = esc_html($_POST['log']);
        return new WP_Error(
            'incorrect_password',
            sprintf(
                __('<strong>Error:</strong> The password you entered for the username %s is incorrect.'),
                '<strong>' . $username . '</strong>'
            ) .
            ' <a href="' . wp_lostpassword_url() . '">' .
            __('Lost your password?') .
            '</a>'
        );

    }

    return $user;
}

This code is enabled by the limit_password_length feature in the Enhancer MU Plugin.

2. Hide sensitive data WordPress leaks in REST API

WordPress uses Gravatars by default, which are MD5 from the user’s or commenter’s email address. These are relatively easy to crack back to email addresses and expose personal information. To make it harder to retrieve, we filter out this sensitive data using the REST API:

function lynt_remove_sensitive_data_from_rest_user( $response ) {
   
   if(!current_user_can('list_users')){
   
     //get WP_REST_Response
     $data = $response->get_data();
     //unset sensitive fields
     if(preg_replace('/[\W]+/', '',$data['name']) == preg_replace('/[\W]+/', '',$data['slug'])) $data['name']="Author";
     unset($data['link']);
     unset($data['slug']);
     unset($data['avatar_urls']);
     //set data back
     $response->set_data($data);
   }
   return $response;
}

function lynt_remove_sensitive_data_from_rest_comment( $response ) {

   if(!current_user_can('list_users')){

     //get WP_REST_Response
     $data = $response->get_data();
     //unset sensitive fields
     unset($data['author_avatar_urls']);
     //set data back
     $response->set_data($data);
   }
   return $response;
}


add_filter( 'rest_prepare_user', 'lynt_remove_sensitive_data_from_rest_user');
add_filter( 'rest_prepare_comment', 'lynt_remove_sensitive_data_from_rest_comment');

This codes are enabled using the filter_rest_users and filter_rest_comments feature in the Enhancer MU Plugin.

The best solution is to disable Gravatars completely in Settings – Discussions – Avatar Display (even if you don’t use comments).

I discovered this problem back in 2016 and started to actively draw attention to it. The WordPress Core team labeled me an alarmist and sent me their own email addresses as proof that it’s a minor issue. Fortunately, the creators of the security plugins didn’t see it as a trivial matter.

Personally, I have actively exploited this issue myself while uncovering networks of disinformation sites.

3. Block direct access to PHP files

Many attacks exploit direct access to PHP files, so we block direct access to PHP files by default. There are only a few PHP files that the installed WordPress needs to access:

Folder /

  • /index.php
  • /wp-cron.php (but even here you can restrict access)
  • /wp-login.php (here we can apply for example geo-blocking)
  • /wp-comments-post.php (but only if we have comments enabled)

Here is also a possible little trick, where you disable access to wp-login.php and wp-comments-post.php for requests that do not have your site’s referrer filled in. It’s not a major security restriction, but you’ll limit some bots trying to try logging in and posting comments themselves, and you won’t waste power on them.

Folder /wp-admin

The global access must be to /wp-admin/admin-ajax.php (possibly also to admin-post.php, but that is rarely used nowadays).

In addition, to work with the administration, you must have access to most of the PHP files in the first level of the wp-admin folder. However, access to these may already be limited.

Folder /wp-includes

No direct access to any files is needed here now.

Folder /wp-content

This is where it can get a bit tricky, as some plugins have endpoints directly in their folder and don’t communicate via the REST API or admin-ajax.php as usual.

However, you can safely block PHP in the /wp-content/uploads folder.

If you use plugins and templates that communicate by standard way, you can disable access to PHP files for the entire /wp-content. Alternatively, you can make exceptions for specific PHP files that need to be called directly. I encounter this most often with plugins for various feeds in WooCommerce.

If you don’t want to deal with the potential problems that blocking can cause, I recommend at least blocking xmlrpc.php and php in uploads.

Blocking xmlrpc.php was extremely useful until WP 4.4, when you could test thousands of passwords with a single query using system.multicall. In current versions, if one authentication attempt fails, all others are automatically discarded. However, the vast majority of xmlrpc requests are malicious, and can cause DoS or send spam comments. Therefore, it is still a good idea to block requests to this endpoint in most cases.

Here are the rules for doing that in .htaccess:

#WP - block xmlrpc.php
<FilesMatch "^(xmlrpc\.php)">
    order deny,allow
    deny from all
    #Allow JetPack 
    #https://jetpack.com/support/how-to-add-jetpack-ips-allowlist
    allow from 122.248.245.244/32
    allow from 54.217.201.243/32
    allow from 54.232.116.4/32
    allow from 192.0.80.0/20
    allow from 192.0.96.0/20
    allow from 192.0.112.0/20
    allow from 195.234.108.0/22
</FilesMatch>

#WP - block PHP in uploads
RewriteRule ^(.*)/uploads/(.*)\.php$ - [F]

4. Return 401 on unsuccessful login and block them server-side

If login attempts fail, we return HTTP code 401. This allows us to easily block IP addresses that try to guess passwords using Fail2Ban or CrowdSec.

function lynt_failed_login_401() {
  status_header( 401 );
}
add_action( 'wp_login_failed', 'lynt_failed_login_401' );

This code is enabled by the failed_login_401 feature in the Enhancer MU Plugin.

We really like to use tools like Fail2Ban. We currently have a 3 layer filter to block suspicious IP addresses.

  • Layer 1: IP causes many 404s (tens, hundreds per unit time) – this is typically a symptom of vulnerability scanning.
  • Layer 2: IPs accessing strange URLs like xmlrpc, various dumps, downloads etc. (few per unit time) – more active targeted scan or attack attempts
  • Layer 3: IP accesses a currently exploitable URL – we actively modify this list based on what’s happening in the community and from our honeypot data.

5. Audit log

We audit user behavior in WP. Our solution currently logs events such as logins, password changes, user creation and role changes, publishing and content changes, settings changes, plugin and template installations/activations, and other events to the PHP error log or syslog.

We have implemented the logging of these basic events as the second part of the Enhancer MU Plugin. In it you can easily choose to log more than 20 different events.

[21-Apr-2024 14:26:05 UTC] WP Audit: {"type":"user_login_success","site":"web.stage.local","timestamp":1713709565,"user_ip":"192.168.1.199","user_id":0,"user_name":"no-user","user_privileges":"none","message":"User smitka logged in","details":{"user_id":1,"user_login":"smitka","roles":["administrator"]}}
[21-Apr-2024 14:26:17 UTC] WP Audit: {"type":"user_updated","site":"web.stage.local","timestamp":1713709577,"user_ip":"192.168.1.199","user_id":1,"user_name":"smitka","user_privileges":"high","message":"User account smitka updated","details":{"user_id":1,"user_name":"smitka"}}
[21-Apr-2024 14:26:27 UTC] WP Audit: {"type":"post_update","site":"web.stage.local","timestamp":1713709587,"user_ip":"192.168.1.199","user_id":1,"user_name":"smitka","user_privileges":"high","message":"Post DNS of type post was saved with status publish","details":{"post_id":1,"post_type":"post","post_title":"DNS","post_status":"publish","origin":"rest-api"}}
[21-Apr-2024 14:26:36 UTC] WP Audit: {"type":"media_uploaded","site":"web.stage.local","timestamp":1713709596,"user_ip":"192.168.1.199","user_id":1,"user_name":"smitka","user_privileges":"high","message":"File favicon.jpg uploaded to media gallery","details":{"post_id":442,"file_path":"\/var\/www\/20d0da50\/public_html\/wp-content\/uploads\/2024\/04\/favicon.jpg"}}

Be careful with error_log function. If you have WP_DEBUG_LOG enabled, the audit logs will appear in the WordPress debug file too (by default /wp-content/debug.log)

Alternative plugins with a nice GUI:

6. Block requests reveals information

Here are the main 3 areas where we block

Dot prefixed files and directories(.git, .env, .htaccess,…)

We block access to all files and folders that begin with a dot except of .well-known: https://smitka.me/open-git/#mitigation

Files reveals app structure

These files usually provides information about exact versions of various plugins etc. We return 404 for them.

  • .*readme.txt
  • .*readme.html
  • .*readme.md
  • .*changelog.txt
  • .*license.txt
  • /wp-includes/rss-functions.php

Username harversting

We block username harvesting with url ?author= – this is one way an attacker can easily get usernames for password guessing.

7. Basic firewall to block common attacks

We use a basic 7G firewall to block the most common attacks: https://perishablepress.com/7g-firewall/.

We used to make our own rules, but this firewall has really proven itself to be a basic blocker of the most common attacks, malicious user agents and vulnerability scanning attempts.

For a more secure environment, we use the standard mod_security, but due to its impact on performance, we use it mainly for POST requests, where we take advantage of the fact that unlike the web server, it can see into their content.

We are currently monitoring the Wafris project and are looking forward to when it comes with Nginx support. Another tool that is in our sights for hands-on testing is OpenAppSec. If you have experience with any of them, share your opinion in the comments.

8. Basic HTTP security headers

The HTTP header security serves as an additional layer of protection.

  • X-Frame options
  • X-Content-Type Options
  • Strict-Transport-Security
  • Permissions-Policy
  • Referrer-Policy

These headers usually have no side effects if the site is working properly on HTTPS.

The most powerful security header is CSP – Content-Security-Policy. It specifies exactly what scripts, frames, images and other resources can be retrieved from where. However, setting it up can be very tricky, especially if the site uses all sorts of external marketing scripts, captchas etc. In that case, it’s best to use the strict-dynamic property and tag your GTM or other tag manager with a one-time nonce.

Another extremely useful feature of CSP is reporting, where it will send information about violations of set policies to a designated endpoint. This will let you know that you have set something up wrong or that something suspicious is happening on the site. Because this reporting produces quite a bit of noise, it’s a good idea to use some sort of service to aggregate these reports.

9. Modify behavior of some PHP functions

We made custom PHP extension that change the behavior of eval, putenv, and possibly other functions.

It hooks functions before calling it to sanitize, log, or block – e.g. when there are so many base64_decodes and gzinflates 🙂.

We have got the inspiration for this extension from here.

Alternative solution is for this kind of virtual patching is this comprehensive extension https://github.com/jvoisin/snuffleupagus

10. Auto logout users on IP change

There are several ways that an attacker can get access to authentication cookies even though they have http-only flags. For example, it could be through PHPinfo, a leak through a system along the way, or often through malware on your computer or a malicious browser extension.

If an attacker gets the authentication cookies, he can log into WordPress admin and do a lot of damage.

To prevent this, we can use session_tokens in WordPress, which also store the IP address of the logged-in user. On each access to the administration, we compare if the IP addresses match, and if not, we destroy all sessions, including the current one.

The disadvantage of this method is that the user can only have one current logged in session and if his IP address changes frequently, he will be prompted to log in again more often. You can also use an ASN (address range identifier) or other weaker identifier instead of the IP itself to reduce frequent user logouts using GeoIP.

function lynt_new_ip_invalidate_sessions() {
    if (is_user_logged_in() && current_user_can( 'manage_options' )) {

		if (isset( $_SERVER['HTTP_X_WP_NONCE']) ) {
			$rest_nonce = $_SERVER['HTTP_X_WP_NONCE'];
			if ( wp_verify_nonce( $rest_nonce, 'wp_rest' ) ) {
				return;
			}
		}
		
		if (isset($_REQUEST['wp_scrape_key']) && isset($_REQUEST['wp_scrape_nonce']) ) {
			$scrape_key = $_REQUEST['wp_scrape_key'];
			$scrape_nonce = $_REQUEST['wp_scrape_nonce'];
			$stored_scrape_nonce = get_transient('scrape_key_' . $scrape_key);
			if ($scrape_nonce === $stored_scrape_nonce) {
				return;			
			}
		}

        $user_id = get_current_user_id();
        $current_ip = $_SERVER['REMOTE_ADDR'];
        
        $session_tokens = get_user_meta($user_id, 'session_tokens', true);
        $sessions = maybe_unserialize($session_tokens);
        
        if (is_array($sessions)) {
            foreach ($sessions as $token => $session) {
                if ($session['ip'] !== $current_ip) {
                    WP_Session_Tokens::get_instance($user_id)->destroy_all();
                    break;
                }
            }
        }
    }
}
add_action('init', 'lynt_new_ip_invalidate_sessions');

This code is enabled by the auto_invalidate_sessions feature in the Enhancer MU Plugin.

The code handles 2 states when the user request does not come from the user’s IP, but from the website server’s IP – WordPress calls itself. This happens when testing the REST API in the Site Health function and in the template and plugin file editor, when testing if a change will break the site. However, it’s a good idea to disable the template and plugin file editor – define( 'DISALLOW_FILE_EDIT', true );. It only enforces this policy on users with high permissions (manage_options), but you can set it to suit your needs.

11. Blocks requests to admin not initiated by user

XSS is a huge problem in WordPress. If an admin opens a page with attacker code, the attacker can perform many actions on their behalf – most commonly installing a malicious plugin or creating a new admin user.

The way it works is that because of XSS (even on frontend), the attacker executes Ajax/XHR requests in the background. Let’s show this with the example of creating a user – it takes 2 queries.

  1. GET to /wp-admin/user-new.php, parse the nonce value _wpnonce_create-user from this page.
  2. POST to /wp-admin/user-new.php where already sends the data to create a new user, the retrieved _wpnonce_create-user and the _wp_http_referer parameter with the value /wp-admin/user-new.php to pretend that the request is coming from the administration.

The _wp_http_referer parameter should be used to make the referrer more reliable in cases where, for example, it is restricted by HTTP headers or various privacy extensions. Unfortunately, it can also be abused to bypass detection of where the request is coming from. I’ll try to put a suggestion in WP core to reverse the priority of the evaluation and if there is a referrer from the browser, to give it priority.

https://github.com/WordPress/WordPress/blob/6bc444cc42c4041379ba46a2569c793b3aea46de/wp-includes/functions.php#L1994

//Experimental
function lynt_refcheck()
{
    if (is_admin() && !defined('DOING_AJAX') && !defined('DOING_CRON')) {
        if (!isset($_SERVER['HTTP_SEC_FETCH_USER']) ||  $_SERVER['HTTP_SEC_FETCH_USER'] !== "?1") {
            die();
        }
        if ($_SERVER['REQUEST_METHOD'] == 'POST') {
            $referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';
            if (strpos($referer, admin_url()) === false) {
                die();
            }
        }
    }
}

add_action('admin_init', 'lynt_refcheck');

This code is enabled by the admin_actions_check_referrer feature in the Enhancer MU Plugin.

There are two controls.

We have been using referrer check in POST requests to the administration for a long time. The purpose of this check is to block requests that would originate from XSS on the frontend.

More recently, we also use a new Sec-Fetch-User header.

Sec-Fetch-User is sent by modern browsers and allows you to distinguish whether the request was created by user action (typically submitting a form) or was automated by some script. This way we can block requests for actions in the administration made by some XSS vulnerability.

I consider this to still be an experimental feature as it may not work with all plugins. If a plugin will call javascript administration functions without using admin-ajax or rest api, it may be blocked. The solution could then be to whitelist some requests to admin.php. However, we do not observe any problems in our environments.

12. Automatic maintenance tasks

We automatically check the consistency of wp-core and plugins using wp-cli:

  • wp core verify-checksums
  • wp plugins verify-checksums

You can get more useful checks with WP CLI Doctor command.

Also checks existence of vulnerable plugins from WordFence Vulnerability Data Feed.

13. Keep track of what’s going on

Honeypots

We run several honeypots – fake WordPress sites that just collect information about the attacks that take place on them. This way we get information about new exploit attempts and are able to react to them.

Honeypots are the most valuable source of current actionable information for me.

The easiest way to run your own honeypot is an empty page with an HTTPS certificate that returns 200 on each request and logs the contents of POST requests. A few minutes after launch, you’ll see the first scan requests 😉

Blogs, RSS, Vulnerability databases

The other very important part of trying to stay up to date is the community, so I follow several resources to learn about current issues:

Modern channels

To be honest, I’m not too friendly with social media, so I don’t have many tips on what to follow. Good security information is appearing on the HackTricks Twitter and you can of course follow me there as well.

I’m also active on a hacking community discord Blueteamer.

I’ll be happy if you share your favorite channels to follow on social media in the comments.

Bonus: Baseline Security Checklist

  • Do I have any non-production files on the site?
  • Are the individual sites isolated from each other?
  • Are directory listings and error display disabled?
  • Are user inputs properly handled?
  • Do users have the correct roles and strong passwords?
  • Are stored passwords strongly hashed (bcrypt)?
  • Am I using secure HTTP headers?
  • Do I have HTTPS set up correctly?
  • Do updates and backups really work?
  • Is the site disclosing sensitive data?


Discover more from Vladimir Smitka

Subscribe to get the latest posts to your email.

Leave a comment

About Me

My name is Vladimir Smitka and I’m a security researcher/hobbyist from the Czech Republic. I’m also the owner of Lynt, a PPC Agency. I’m also an active member of the Czech WordPress community and one of the WordCamp Prague organizers.

OPEN .GIT GLOBAL SCAN

  • 230 000 000 sites scanned 🔍
  • 390 000 sites affected 😥
  • 100 000 mail send to the developers 📧
  • 150 000+ sites fixed 
  • 100+ possitive comments 🗨️
  • 3500+ thankyou mails ❤️
  • Thousands and thousands sites with another serious issue found 😑

For my research I use affordable Virtual Private Servers from Digital Ocean (they have a great infrascruture), Linode (they have a great understanding for my work) and dedicted servers from Hetzner.

If you like my research, you can make a small donation for coffee and VPS – two basic ingredients for my future security scans.

Follow me

Our Projects

Latest Articles