#178 Steps Developers Should Take to Secure WordPress


Some assume Wordpress is littered with security holes. This is NOT the case. Wordpress, just like any other CMS, is only as secure as you make it. It requires a secure server and database as well. However, nothing you will ever create on the internet will be 100% secure. It just isn’t possible. What IS possible is blocking most attacks in a few simple steps.

Last update on Dec 4, 2013

Important Note

AJAX Concerns

WordPress’s handling of AJAX in plugins is a bit sketchy. Even for the front-end view, it will call the ajax file (admin-ajax.php) from /wp-admin and by default, run the plugin with admin privileges. An incorrectly written plugin that does not properly check for user permissions may have security issues. You can find more info here.

Database Concerns

Another kind of sketchy thing is how WordPress handles database connections. All database connections are handled with mysql_connect(), even as of version 3.6. You can see this here. mysql_connect() and its family of methods are deprecated in PHP 5.5.0. I’m guessing this is because mysql_connect() does not support prepared statements, making it a less secure way to connect to MySQL. Prepared statements will sanitize query values for you. When you use them, you prevent SQL injection attacks.

WordPress remains secure because it has methods built in (e.g. $wpdb->prepare()) to sanitize data before it hits the database. However, the onus to actually use these functions and use them correctly is on the plugin/theme developer.

XMLRPC Concerns

In version 3.7, WordPress deemed that xmlrpc.php was as safe as the rest of its core code, so it is now left on by default. You can’t turn it off in wp-admin and nor can you turn it off through functions.php. There are currently no known vulnerabilities in xmlrpc.php. However, hackers will still automate attacks against the file. This sometimes results in sites going down due to maxing out connections. There are other ways to negate this sort of attack, but here’s my take on the solution.

If you’re running a WordPress version less than 3.7 hackers might be able to get inside your WordPress.

Other Notes

It’s best to combat this as you would when you’re using any other CMS – keep your CMS, plugins and themes up to date. Nothing on the internet is 100% secure. The community at WordPress is large and experienced – they catch and fix things fast. Always, always update your stuff.

Step 1: Have a Backup

This is a MUST. Create frequent backups of your database and filesystem. In my experience, it’s not hackers that usually bring a site down, but a friendly site admin who hit a wrong setting or a developer who missed a step. You should always have backups so you can rollback to before the site crashed, just in case.

Step 2: Use Logging Instead of Outputting Errors to the Screen

Enable logging for Apache errors, PHP errors, MySQL errors and optionally, who logs into your server. Instead of outputting PHP errors directly to the website screen, capture them in logs. Showing errors directly on the screen is a security risk, as a potential hacker learns at least two things – where your code is and what it was attempting to do. You can configure PHP’s error reporting this way by editing wp-config.php accordingly.

wp-config.php

//Enable all errors except Notices
ini_set('error_reporting', 'E_ALL ^ E_NOTICE');

//Turn off error reporting directly to the screen
ini_set('display_errors', 0);

It always will depend on your server host, but here are some ways for you to track down your error logs.

Apache (credit) – may be different depending on host
grep ErrorLog /usr/local/etc/apache22/httpd.conf
grep ErrorLog /etc/apache2/apache2.conf
grep ErrorLog /etc/httpd/conf/httpd.conf

MySQL
grep log /etc/my.cnf

PHP
grep error_log /etc/php.ini

Failed Login Attempts (credit) – only return failed login attempts from this log
grep "authentication failure" /var/log/secure | awk '{ print $13 }' | cut -b7- | sort | uniq -c

Step 3: Change the “Who Can Connect and How” for Your Server, Database, and WordPress Admin Panel

The key to having a secure website is to have a secure server. You cannot have one without the other. On your server, database and wordpress install, the access credentials need to be hard to guess. You’ll also need to have a secure server and database. We’ll briefly go over how to do this, but there will be more details on how to do this for WordPress after this section.

The Do-Not’s

  • Do not connect to your server with the standard ports (e.g. 21 [FTP] and 22 [SFTP/SSH])
  • Do not use dictionary words for your passwords
  • Do not leave root access on for your server (make sure you’ve created a user you can log in with before doing this)
  • Do not leave in the MySQL test user – aka ''@localhost and ''@'%'
  • Do not have the root MySQL user called root (make sure you’ve created a user you can log in with before doing this)
  • Do not make the WordPress admin’s username just “admin”

The Do’s

  • Limit the users who can SFTP/SSH into your server (here)
  • Have a firewall on your server that filters SSH (here)
  • Disable SSH Protocol 1 and use Protocol 2 (here)
  • Change the SSH and FTP port on your server (here)
  • Use SSH or SFTP to connect to your filesystem
  • Use lowercase letters, uppercase letters, symbols (e.g. _-@#) and numbers in your passwords (here)
  • Log in to your server with a different user and su to root
  • Delete the MySQL test user – aka ''@localhost and ''@'%'
  • Rename the MySQL user of 'root'@'localhost' to 'anything_else_you_can_think_of'@'localhost'
  • Add skip-networking in /etc/my.cnf to disallow external connections to MySQL (here)
  • Make the WordPress admin user something other than “admin”

Step 4: Edit Generic Apache Settings

Next, we need to disable file listing when you visit a directory from a website and we need to set it up so Apache will listen to the overrides we’re about to give it in the next few sections.

/etc/httpd/conf/httpd.conf

#disable indexes globally
Options -Indexes

# replace /somefolder with the absolute path of your WP document root
<Directory /somefolder>
    Options FollowSymLinks
    AllowOverride All
</Directory>

Step 5: Edit wp-config.php Settings

Next up:

  • make it so someone can’t edit .php files from the admin
  • make it so someone can’t install plugins, themes, etc. from the admin
  • change how WordPress connects to your install (here)
  • use an alternate prefix for your table names
  • change the placement of WordPress’s user and usermeta tables

Based on a few google searches, it appears that one of the more common WordPress hacks employ the use of SQL injection against incorrectly written WordPress plugins. A few plugins do not escape data correctly in their database queries and hackers take advantage of it, turn what should only be a value in a query into an actual query. The hacker’s query usually tries to hit the usermeta and user tables, asking for the admin user’s records so they can log into wp-admin and edit things.

We’re going to set up some last-ditch efforts – turn off code mods, turn off installs, etc. We’re also aiming to thwart the actual start of the problem – SQL injection – by editing the table name prefix and the location of the usually queried to tables, _users and _usermeta.

wp-config.php

//you cannot change .php files from the admin anymore
define('DISALLOW_FILE_EDIT', true);

//you can't install plugins, themes, etc. from the admin area either
define('DISALLOW_FILE_MODS',true);

//FS_METHOD should be anything aside from "direct," as "direct" will open up holes in poorly configured servers
define('FS_METHOD', 'ftpext');

// WARNING: IF YOU CHANGE THE TABLE PREFIX AFTER THE INITIAL INSTALL, YOU NEED TO UPDATE THE TABLE PREFIXES IN THE USERMETA AND OPTIONS TABLES
$table_prefix  = 'yourprefix_';   // Only numbers, letters, and underscores please!
define('CUSTOM_USER_TABLE', $table_prefix.'my_users');
define('CUSTOM_USER_META_TABLE', $table_prefix.'my_usermeta');

If you change the table prefix after the initial WordPress install, you MUST change the old table prefix to the new table prefix value in the _options and _usermeta tables or you will get the infamous “insufficient permissions” error once you try to log in wp-admin.

run these queries against your wordpress database

UPDATE new_usermeta
SET meta_key = REPLACE(meta_key,'old_','new_');

UPDATE new_options
SET option_name = REPLACE(option_name,'old_','new_');

There are several other parameters for the filesystem upgrade constants – you can find them here.

Step 6: Add .htaccess Rules in Document Root

WordPress should have already created an .htaccess in your document root. In that .htaccess file is a block of code that has # BEGIN WordPress and # END WordPress comments. DO NOT ADD ANY OF THESE EDITS BETWEEN THOSE TWO COMMENTS. That’s the part of the .htaccess file that WordPress has access to. WordPress may overwrite your changes if you put them between those comments.

We need to block access to wp-includes, protect the wp-config.php file, protect your .htaccess file and disable hotlinking. If you’re the only one who can log into your website, restrict access to wp-login.php. For a giggle as you do this, if ever someone tries to get to a restricted area, redirect them to this clip from the movie Hackers.

.htaccess

# Block the include-only files.
RewriteEngine On
RewriteBase /
RewriteRule ^wp-admin/includes/ - [F,L]
RewriteRule !^wp-includes/ - [S=3]
RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]
RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L]
RewriteRule ^wp-includes/theme-compat/ - [F,L]

# PROTECT WP-CONFIG
<Files wp-config.php>
order allow,deny
deny from all

#OPTINAL - direct all attempts to this file to a hilarious youtube vid from the movie Hackers
ErrorDocument 403 http://www.youtube.com/watch?v=8wXBe2jTdx4
</Files>

# OPTIONAL - IF ONLY YOU CAN LOG IN, PROTECT WP-LOGIN
<Files wp-login.php>
# ONLY ALLOW YOUR IP ADDRESS
order deny,allow
allow from 555.222.333.44
deny from all

#OPTINAL - direct all attempts to this file to a hilarious youtube vid from the movie Hackers
ErrorDocument 403 http://www.youtube.com/watch?v=8wXBe2jTdx4
</Files>

# PROTECT .HTACCESS
<Files ~ "^.*\.([Hh][Tt][Aa])">
order allow,deny
deny from all
satisfy all
</Files>

# BAN ALL FROM XMLRPC.PHP SO HACKERS CAN'T AUTOMATE AN ATTACK AGAINST IT, MAXING OUT CONNECTIONS TO YOUR SITE
<Files xmlrpc.php>
order deny,allow
deny from all
ErrorDocument 403 http://www.youtube.com/watch?v=8wXBe2jTdx4
</Files>

# DISABLE HOTLINKING
<IfModule mod_expires.c>
RewriteEngine on
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^http(s)?://(www\.)?yourdomain.com [NC]
RewriteRule \.(eot|svg|ttf|woff|xml|css|jpe?g|png|gif|js)$ - [NC,F,L]
</IfModule>

Step 7: Add .htaccess Rules in wp-admin

WordPress does not automatically create a .htaccess in the /wp-admin directory, so you’ll have to create one.

cd wp-admin
touch .htaccess

#run the following to check that .htaccess was created
ls -la

What we want to do is prevent all IP addresses except your own (and maybe your client’s) from entering the admin. However, if we’re using ajax on the front-end (e.g. if a plugin is calling admin-ajax.php on the front-end), we allow that file through. Sometimes, ajax-admin.php will also call for some /wp-admin/css/*.css files, so we allow those through as well.

Any plugin calling the admin-ajax.php code could be running as an admin, which if it is, it would be opening up security holes in your WordPress installation.

A responsible plugin developer calling the admin-ajax.php on the front-end would have employed the use of several built-in WordPress functions (e.g. current_user_can()) in order to make sure that the plugin’s admin abilities were disabled on the front-end side. For more information on this issue, click here.

wp-admin/.htaccess

# ONLY ALLOW YOUR IP ADDRESS
order deny,allow
allow from 11.22.33.44 

# IF A PLUGIN IS CALLING ADMIN-AJAX.PHP ON THE FRONT-END
<Files admin-ajax.php>
    Order allow,deny
    Allow from all
    Satisfy any 
</Files>

# PLUGINS MAY ALSO BE CALLING .CSS FILES FROM WP-ADMIN
<Files "*.css">
 Order allow,deny
 Allow from all
 Satisfy any
</Files>

deny from all
ErrorDocument 403 http://www.youtube.com/watch?v=8wXBe2jTdx4

# PROTECT .HTACCESS
<Files ~ "^.*\.([Hh][Tt][Aa])">
order allow,deny
deny from all
satisfy all
</Files>

Step 8: Edit Filesystem Permissions

Editing your filesystem permissions will make your site more secure by further restricting who can see what. Ideally, all your files should be set up so you’re using the most restrictive user and group (e.g. a user that is not root and usually the group is apache) and they should have directories of 755 and files of 644.

# start in your WordPress document root
cd ~/public_html

# change user:group to the most restrictive one
chown -R YOUR_USER:apache .

# change all directories so the owner of the file can read, write and execute the directory, 
# but the directory's group and everyone else who accesses it can only read and execute it
find . -type d -exec chmod 755 {} \;

# change all .php files so the owner of the file can read and write the files, 
# but the file's group and everyone else who accesses it can only read it
find . -type f -name '*.php' -exec chmod 644 {} \;

# change the config.php file so the owner of the file can read and write the files, 
# but the file's group can read it and everyone else can't do anything
find . -type f -name 'wp-config.php' -exec chmod 640 {} \;

# change the .htaccess files so the owner of the file can read and write the files, 
# but the file's group can read it and everyone else can't do anything
find . -type f -name '.htaccess' -exec chmod 640 {} \;

File permissions work like this:

Owner Group Other
is directory
read write execute
read write execute
read write execute
- or d
4 2 1
4 2 1
4 2 1

For the first section of output that shows up when you run ls -la, the first character will be either a - or a d. The - means it is a file. The d means it is a directory. The numbers on the file are actually assigning who can read, write or execute to it.

For the third and fourth sections, those are the user and group that file belongs to. So, for instance:

drwxr-xr-x 10 user1 apache  8192 Jul 30 11:29 wp-includes #directory - 755
-rw-r--r--  1 user1 apache  3692 May  8 23:22 wp-trackback.php #file - 644
-rw-r--r--  1 user1 apache  2722 Mar  4 02:23 xmlrpc.php #file - 644

user1 is the user that owns the xmlrpc.php file, whilst apache is the group that has access to that file.

Community Suggestions

This section contains everything that cannot be added into earlier steps and was suggested to be added by the people who have viewed this article.


social engineering to retrieve wp-admin passwords

Often an oversight. One thing to watch out for is scammers offering support for your plugins/themes (among other things) as long as you provide your login credentials. Beware of this.


disable the WordPress generator meta tag

By default, WordPress outputs the version you’re running to the head of your source code as a meta tag with the name of “generator.” Use this to remove the generator. (thanks john0980 on reddit)

functions.php

remove_action('wp_head', 'wp_generator');

advanced; only allow direct access to .php files that require it

By only permitting certain scripts to be directly accessed in the wp-content directory, you prevent many attacks based on plugin and upload exploits. (thanks to DamnInteresting on reddit)

wp-content/.htaccess

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /wp-content/
# prevent direct access to .php files in this directory
RewriteCond %{REQUEST_URI} !some_whitelisted_script\.php [NC]
RewriteCond %{REQUEST_URI} !another_whitelisted_script\.php [NC]
RewriteRule ^(.*)\.php - [NC,L]
</IfModule>

allowing only certain IP addresses through to wp-login.php but the IP addresses are dynamic

There really isn’t too much one can do in this situation. It appears that you can use hostnames in .htaccess files though, so that may be an option.

.htaccess

# OPTIONAL - IF ONLY YOU CAN LOG IN, PROTECT WP-LOGIN
<Files wp-login.php>
# ONLY ALLOW YOUR IP ADDRESS
order deny,allow
allow from host1.com
allow from host2.com
deny from all

#OPTINAL - direct all attempts to this file to a hilarious youtube vid from the movie Hackers
ErrorDocument 403 http://www.youtube.com/watch?v=8wXBe2jTdx4
</Files>

And now we’re done

Holy crap that took awhile. But hey, now we have a more secure website.

Kimberly Cottrell

I’m Kim Cottrell, a self-entitled professional code ninja. For work, I do WordPress, EE, Ruby on Rails and all sorts of other dorky things. In my spare time, I can be caught hugging it out at Revolution BJJ.

Do you have a sweet project you want to get me involved in? Hire Me

9 Responses to “8 Steps Developers Should Take to Secure WordPress”

  1. Das Chan

    Thank you so very much for all these tips. For those of us admins who have dynamic ips, please clarify whether the following will work for the .htaccess file in wp-admin: –
    #Keep out others from wp-admin
    order deny,allow
    allow from host1.com
    allow from host2.com
    deny from all

    and if, say, my ISP were Comcast, then I would replace ‘host1.com’ with ‘comcast.com’? Would I need ‘comcast1.com’ and ‘comcast2.com’?

    » Reply
  2. Shannon

    Would this be worth doing for your wordpress installation locally? I would like to do this but I’m wondering if I should hold this off until I put my copy on live. Great article! I came here because I have some concerns about securing my local copies and getting my computer hacked.

    » Reply
    • Kim Cottrell

      Ah, do it for live and not your local. I’m assuming you’re using XAMPP or MAMP to run a local version. Usually, you’re not running your computer as a server, so your local environment cannot be accessed from the web, meaning another computer can’t visit your local environment by entering in a domain name.

      You should backup your local copies just incase something goes sour – aka if your HDD dies or a virus eats everything on your computer – but installing XAMPP or MAMP doesn’t mean you’re making your computer less secure. As long as you’re running a virus scanner (Windows only, Mac’s don’t need this) and a firewall (should be there by default), you should be okay.

      » Reply
  3. Travis Brodeen

    I think you mean deprecated not depreciated. Thanks for the article!

    » Reply
    • Kim Cottrell

      Haha! Thanks. I never noticed that.

      » Reply
  4. Gabriel Reiser

    Just so you know, mysql_connect is deprecated not because it so called “lacks prepared statement support” (it has supported that for a long time now) but because it locks you into a database paradigm. PDO is it’s replacement. With that you can specify any type of ODBC database to use based on connection string. It has a standardized interface so you can build once and use any database system you so desire, or even change without having to rewrite your data access layer, or worse, your application.

    » Reply
    • Kim Cottrell

      Interesting. I thought mysql_connect() didn’t support prepared statements but that mysqli_connect() did. I’ve never found documentation that says otherwise on this matter. PDO and mysqli I thought were the replacements for the outdated mysql functions – it says so on at least MySQL’s documentation.

      By “locking you into a database paradigm,” you mean that with mysql_connect(), it locks you into only using MySQL’s database, correct?

      » Reply
      • Gabriel Reiser

        Yes, by using mysql_function’s it locks you into using mysql, which could be a bad thing. mysql_function and mysqli_function are really the same thing just mysqli is an extension that was added to support later mysql functionality. Both are bad. So technically you were correct when stating that mysql_functions themselves don’t support prepared statements but I lump mysqli_functions into that same boat of mysql_functions. PDO is the way to go for PHP. And using magic getters and setters one can write a nice ActiveRecord pattern ORM on top of it like I did once.

        » Reply
        • Kim Cottrell

          I wish I could like wordpress comments. +1 to you, good sir.

          » Reply

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>