How To Secure Your WordPress Website

About The Author

Hello, I’m Daniel and I make things for the web. I’m the CTO at Kinsta and I write for a number of amazing publications like Smashing Magazine and … More about Daniel ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

Security has become a foremost concern on the Web in the past few years. Hackers have always been around, but with the increase in computer literacy and the ease of access to virtually any data, the problem has increased exponentially. It is now rare for a new website to not get comment spam within days of its release, even if it is not promoted at all.

This increase in naughty behavior, however, has spurred developers to write better code, and framework vendors have implemented many functions to help coders in their battle against the dark side.

Further Reading on SmashingMag:

Because data validation and sanitization is a big part of both security safeguards and normal user-input processing, by securing our code we will be not only protecting our behinds, but offering a better, more solid user experience.

securityimage

While a large part of this article is specific to WordPress, a sizeable chunk is about general practices that anyone can use. Even the WordPress-centric sections contain useful logic, so reading them may well be worth it even if you use a different framework.

URL-Based Exploits

With URL-based exploits, hackers try to find weak spots on your website by making requests that would normally return an error but for some reason are completed.

https://mysite.com/trying/to/exploit/%2F/config

The above hypothetical URL is essentially a stab in the dark by a hacker. But if the request is met, even though the URL is clearly not meant to go anywhere, the hacker might be able to make use of it.

Using .htaccess as a Firewall

One of the best methods I’ve found against this kind of threat is an .htaccess firewall. It basically consists of rules that automatically block requests based on strings in the URL.

For example, there is no good reason for an opening bracket ([) to be in a URL. If a request is made using a URL that contains a bracket, then either the user has mistyped something or someone is looking for a security hole. Either way, generating a “403 Forbidden” page is good practice in this case.


RedirectMatch 403 [

Paste the line above in your .htaccess file to block any request that contains an opening bracket.

To guard against more than just brackets, you will need a more complex ruleset. Luckily, our awesome editor Jeff Starr has gone out of his way to create a great .htaccess ruleset. The latest iteration is called 5G Firewall and is freely available from Perishable Press for your copy-and-pasting pleasure.

The firewall is modular, so you can delete lines from it without breaking the functionality. If something goes wrong when you’re using it, you can usually track down the problem by deleting lines until it starts working again. Once you’ve found the offending line, you can delete it and paste back the rest.

Protecting Directories

On many servers, it is possible to view the contents of a directory simply by typing it in the URL bar.

https://myblog.com/wp-content/uploads/2011/08/

Visiting this typical URL for a WordPress blog will list the contents of that directory, showing all of the uploads from August 2011. You might want this, but you can also disable or fine tune it using the good ol’ .htaccess file.


Options -Indexes

Popping the above line into your .htaccess file will disable directory listings; so, users will get a “403 Forbidden” message if they try to view a directory. While many people seem to be aware of this, far fewer know of the other options besides allowing or disallowing access. You can control which file types are listed using the IndexIgnore directive. Take these three examples:


IndexIgnore *
IndexIgnore *.php 
indexIgnore *.jpg *.gif *.png

If directory listing is enabled, then the directory will be displayed in the first example, but no files will be listed because all will be ignored. The second example will list all files except ones with a .php extension. The third example will omit the three image types specified.

Note that some hosts (such as MediaTemple) disable directory browsing by default, so you won’t need to modify the .htaccess file. To verify this, just type a directory location in the URL bar and see what happens.

Additional Server-Level Protection

So far, the measures we have taken have nothing to do with our website’s actual code. However secure your code is, you will still need to implement something like what we did above. We don’t have time to look at all tips and tricks for .htaccess, but you can do quite a few other things:

  • Password-protect directories,
  • Use smart redirects,
  • Deny access based on IP or an IP range,
  • Force downloading of files,
  • Disable hotlinking,
  • The list goes on.

Look at the “Further Reading” section at the end of this article, and become good friends with your .htaccess file. It might seem daunting and confusing at first, but solid knowledge of how to use it will go a long way.

Protecting Against Malicious Users

The second type of problem that can arise is when someone performs an action that they are not supposed to do. This doesn’t necessarily mean that they intended to harm the website, but it could happen.

If users are listed somewhere in the admin part of your website, chances are that a link is displayed to delete each user. The link could point to the script in the following location:

https://mysite.com/admin/scripts/delete_user.php?user_id=5

This link is relatively obscure; that is, a normal user doesn’t have a good chance of stumbling on it. But if directory listings are enabled, then someone naughty could go to https://mysite.com/admin/scripts/, see that you have a delete_user.php file there, and make various requests to try to delete a user.

If the script does not check permission or intent, then anyone who visits the link above could delete user 5.

Authority and Intent

Whenever a user initiates an action, we need to take two things into consideration. Does the user have authority to perform the action (i.e. do they have permission)? If the user does have authority, do they also intend to complete the action (i.e. do they mean to do what they’re doing)?

WordPress has functions to help you make sure that both conditions are met before an action in the script is triggered. We will look at these in detail shortly. If you are building your website from scratch, then you will need to make sure that each user has associated permissions and that you know which action can be performed under which condition.

For example, you would probably want only administrators to be able to delete content from the website. Every time a user tries to delete content, you would need to make sure that they are actually an administrator — this is the “authority” part.

Intent is best described with an example. Let’s assume you can use the following link to delete a comment:

https://mysite.com/admin/scripts/delete_comment.php?comment_id=5

The script itself will check that the user is currently logged in and is an administrator, so it takes care of the authority check. Could someone still wreak havoc? Sure they could! A sneaky hacker could put a link on their own website pointing to the same location:

<a href="https://mysite.com/admin/scripts/delete_comment.php?comment_id=5">Super-Happy Times Here!</a>

Because everyone likes super-happy times, many users would click the link. In 99% of cases, nothing would happen, because those visitors would not be administrators of mysite.com. But if a logged-in administrator of mysite.com did click on the link, then the action would execute, even though the link was actually clicked from vilehackerperson.com.

You might think that the odds of this happening are astronomical. In a way you’d be right, but remember that a hacker can do this extremely easily and can automate it. Millions of people get spam email saying that Bill Gates will take away the Internet unless they pay $1,000. Most recipients don’t see the email or throw it out or mark it as spam or what have you, but perhaps 1 out of every 2 million people is lured in. A thousand bucks for basically doing nothing is not bad at all. And a hacker probably wouldn’t put the link on their own website; all they would need to do is hack a big website and embed the link there without anyone noticing.

Checking For Authority In WordPress

WordPress has a permissions system built in referred to as “Roles and Permissions.” Capabilities are the basis of the whole system; roles are just a way to group a set of capabilities together.

If a user has the delete_posts capability, then they have the authority to delete posts. If a user has the edit_posts capability, then they can edit their posts. Quite a few capabilities are available, and you can even create your own.

Roles are basically groups of capabilities. A user with the role of “contributor” has three capabilities: read, delete_posts and edit_posts. These give the user the authority to read posts and to edit or delete their own posts. These capabilities could be granted individually to any user, but grouping them into frequently used bundles is much easier and more practical.

With that in mind, let’s look at how to use WordPress functions to ensure that a user has the authority to complete an action that they initiate.


if(current_user_can("delete_users")) {
    wp_delete_user(5);
}
else {
    die("You naughty, naughty person. Of course, you could just be logged out…");
}

Here, we’ve made sure that the user has the delete_users capability before they are able to complete the action. Don’t make premature assumptions when protecting your scripts; in many cases, especially those of authority, the intent is not malicious.

The current_user_can() function takes one argument, which can be a role or a permission. We could let “editors” (who don’t normally have the delete_users capability) delete users in the following way:


if(current_user_can("editor")) {
    wp_delete_user(5);
}
else {
    die("You must be an editor to delete a user");
}

Be careful with the method above because the roles are not inclusive. This function doesn’t require the user to be at least an editor; it requires them to be exactly an editor (if that makes sense). Because of this, I find it preferable to use capabilities, especially if I have modified the default permissions extensively.

Two other similar functions enable you to examine the capabilities of users other than the currently logged-in one.


if(user_can(5, "manage_links")) {
    echo "User 5 is allowed to manage links";
}
else {
    echo "Sadness! User 5 may not manage links";
}
if(author_can(1879, "update_themes")) {
    echo "The author of post #1879 is allowed to update themes";
}
else {
    echo "Oh noes, our friend, the author of post #1879 may not update themes";
}

The user_can() function checks whether a given user has the given capability (or role). The first argument is the user’s ID or a user object; the second argument is the name of the capability or role that we want to check for.

The author_can() function checks whether the author of a given post has the given capability (or role). The first parameter should be a post ID or a post object; the second is the capability or role that we are examining.

Checking For Intent In WordPress

Intent is a bit more difficult to check. In the good ol’ days, a check of $_SERVER[‘HTTP_REFERER’] was the way to go. This stored the page that the user came from. If the domain name was their own, then the user was probably OK… unless, of course, someone had gotten into their files and inserted a link that deleted the user’s database if they clicked on it as an administrator.

A newer more secure method was implemented in WordPress 2.03 — quite some time ago — called nonces. Nonce stands for “number used once” and is used frequently in cryptography to secure communications. It is a number that is generated before an action is initialized, then attached to the action’s call, and then checked before the action completes.

In WordPress, you would generally use nonces in one of two places: forms and normal links. Let’s look first at how to generate a nonce in a form.

Nonces in Forms


<form id="myform" method="post" action="myawesomescript.php">
    <h2>Enter an awesome word here</h2>
    <input type='text' name='word'>
    <?php wp_nonce_field( 'awesome_name_nonce') ?>
</form>

This will generate a hidden input field containing your generated nonce, which will be sent along with all of the form’s other data. The wp_nonce_field function takes four parameters:

  1. The first parameter is optional, but recommended because it gives the nonce a unique identifier.
  2. The second parameter is the name of the field. This is also optional and defaults to _wpnonce.
  3. The third parameter is boolean. If set to true, it will also send the referrer for validation.
  4. The fourth parameter is also a boolean and controls whether the field is echoed right then and there.

The resulting hidden field would look something like this:


<input type="hidden" id="_wpnonce" name="_wpnonce" value="d6d71w4664">

Setting all of this up won’t make a huge difference if it isn’t used when the form is actually processed. We need to check for the presence and the value of the nonce before allowing any actions to be performed. Here is one way to do that:


if (!wp_verify_nonce($_POST['_wpnonce'],'awesome_name_nonce') ) {
   die('Oops, your nonce didn't verify. So there.');
}
else {
   awesome_word_inserter($_POST["word"]);
}

Here, we’ve used the wp_verify_nonce() function to make sure that our nonce is correct. This function takes two parameters: the first is the value of the nonce field, and the second is the name of the action that we defined (this was the first parameter for the wp_nonce_field() function). If the nonce is verified, then the function will return true; otherwise, it will return false.

In some cases, you will want a link, instead of a form, to perform an action. This would typically look like our previous examples:

https://mysite.com/admin/scripts/deletethatthing.php?thing_id=231

To generate a nonce for a link, we can use the following method:


$base_url = "https://mysite.com/admin/scripts/deletethatthing.php?thing_id=231";
$nonce_url = wp_nonce_url( $base_url, "thingdeleter_nonce");
echo "<a href='".$nonce_url."'>Delete that thing</a>";

The resulting link would be something like this:

https://mysite.com/admin/scripts/deletethatthing.php?thing_id=231&_wpnonce=d6f77f1364

When we actually go to the script, we can check the nonce using the same method as before:


if (!wp_verify_nonce($_GET['_wpnonce'],'thingdeleter_nonce') ) {
   die('Oops, your nonce didn't verify. So there.');
}
else {
   delete_that_thing($_GET["thing_id"]);
}

Checking Authority And Intent At The Same Time

We need to look at both aspects at once; although, now that we’ve looked at all of the components, it won’t exactly be rocket science! Let’s take a simple link that lets the user delete a comment. We would have this on the page that lists comments:


$nonce_url = wp_nonce_url("https://mysite.com/scripts/delete_comment.php?comment_id=1451", "delete_comment_nonce");
echo "<a href='".$nonce_url."'>dispose of this comment</a>";

And here is the script itself:


if (wp_verify_nonce($_GET['_wpnonce'],'delete_comment_nonce') AND current_user_can("edit_comment")) {
   wp_delete_comment($_GET["comment_id"]);
}
else {
   die('Oops, your nonce didn't verify, or you are not permission-endowed enough.');
}

Data Security

Our work might seem to be done, but if you’ve been developing for a while, then you know it never is. An additional layer of security needs to be added to stop insecure data (or erroneous data) from entering our database. Adding this additional layer is called data sanitization.

A quick clarification: data sanitization refers to the process of cleaning up our data to make sure that nothing suspicious gets sent to the database. Validation refers to all of the checks we perform on data to make sure they are the types of data we need; it is typically done to ensure that the user has entered a valid email address, a well-formed URL, etc. The two terms are sometimes used interchangeably, and other methods may be similar, but they are quite different things. For our purposes, sanitization is a bit more important, because it has more to do with security.

The main thing we are trying to protect against is SQL injection. SQL injection is a technique used by hackers to exploit a database’s weaknesses. Take the following example:


// A hacker goes to your search field and searches for elephant' - note the apostrophe at the end. In the script, the following SQL is run:
SELECT ID, post_title FROM wp_posts WHERE post_title LIKE '%elephant'%'

In the example above, the user’s search for elephant’ has resulted in unclosed quotes in your script. While the hacker might not be able to do much with this, an error message would be generated, indicating to them that at the very least you are not sanitizing your data.

In some cases, the SQL itself could be harmful or could give the hacker much more information than you’d like. Take the example of an administrator being able to enter a user’s log-in name in a form and getting back the user’s email address.


SELECT user_email FROM wp_users WHERE user_login = 'danielp'

If the hacker manages to perform an SQL injection attack, they could type ’ OR 1=1 ‘ in the form, which would result in the following query:


SELECT user_email FROM wp_users WHERE user_login = ’ OR 1=1 ’

This would return all email addresses in the database, because we would be retrieving all addresses for which the user’s log-in name is an empty string, or 1=1, which is always true.

There are two ways to protect against this kind of problem — implementing both is good practice. In round one, we validate the data. If an email address needs to be entered, we can filter out user data that does not conform to the format of email addresses. We simply make sure that the format is right, otherwise we redirect the user, stating that the address is invalid. If the data passes round one, we move to round two, where we remove all characters that could mess up the query. This usually entails escaping quotes so that they can be used only as actual quotes in the SQL query.

When working without a framework, you would typically use addslashes() or something similar, but WordPress offers its own solution…

Data Sanitization In WordPress

When communicating with the database in WordPress, the preferred method is to use the $wpdb class. You can read all about this in “WordPress Essentials: Interacting With the WordPress Database.” The class offers a number of methods to alleviate your SQL injection worries.

Before jumping in, let’s look at some examples to get a basic understanding of how the class works.


// Perform any query
$wpdb->query("DELETE FROM wp_users WHERE user_id = 5");
// Get one column of data
$posts = $wpdb->get_col("SELECT post_title FROM wp_posts WHERE post_status = 'publish' ORDER BY comment_count DESC LIMIT 0,10");
// Get a row of data
$post = $wpdb->get_row("SELECT * FROM wp_posts WHERE ID = 1453");
// Get multiple rows and columns
$posts = $wpdb->get_results("SELECT ID, post_title, post_date FROM wp_posts WHERE post_type = 'publish' ORDER BY post_date DESC LIMIT 0, 12 ");
// Get a single value 
$author_id = $wpdb->get_var("SELECT post_author FROM wp_posts WHERE ID = 2563");
// Insert a record
$wpdb->insert("wp_postmeta", array("post_id" => 2323,  "meta_key" => "favorite_count", "meta_value" => 224 ), array("%d", "%s", "%d"));
// Update a record
$wpdb->update("wp_postmeta", array("meta_value" => 225), array("meta_key" => "favorite_count", "post_id" => 2323), array("%d"), array("%s", "%d"));

The insert() and update() methods are helper methods, and they’re great because, apart from modularizing your database interactions a bit, they also take care of sanitization for you. If you want to use the general query() method, though, you will need to take care of it on your own.

The easier way is just to use the escape() method:


$data = $wpdb->escape($_POST[about_me]);
$wpdb->query("UPDATE wp_usermeta SET meta_value = '$data' WHERE meta_key = 'description' AND user_id = 154  ");

A slightly harder but better way to go about this is to use the prepare() method. An example from the WordPress Codex illustrates this perfectly:


$metakey = "Harriet's Adages";
$metavalue  = "WordPress' database interface is like Sunday Morning: Easy.";
$wpdb->query( $wpdb->prepare( 
   "
      INSERT INTO $wpdb->postmeta
      ( post_id, meta_key, meta_value )
      VALUES ( %d, %s, %s )
   ", 
        10, 
   $metakey, 
   $metavalue 
) );

Further Protection Using Sanitization

Sanitization is a fairly big topic and requires quite some time to master. For now, you’ll be busy mostly determining which characters to allow and which to disallow, and then finding ways to parse out the latter. Some common needs are to parse HTML out of addresses, filter numbers out of strings, validate email addresses and so on, but you will need to implement your own solutions for more complex needs. See the “Further Reading” section for more on this topic.

Final Thoughts

The measures needed to secure a website cannot be discussed in a single book, let alone a poor article. There are many methods and topics we did not look at, such as advanced password encryption, salts and so on. But hopefully, by implementing what we’ve discussed, your website will be much safer. Hackers usually go for the weakest link, so if your website is not insanely popular and is fairly secure, you should be OK.

While I have a lot of experience in this field, I am far from being a security expert. If you know of any other or better methods, do share them in the comments. There is always something new to learn about website security.

General Reading

WordPress-Specific Reading

Smashing Editorial (al)