Set File Permissions with Exact chmod Values
Incorrect file permissions are one of the most common ways WordPress sites get compromised. Set them wrong in either direction and you are either exposing writable files to the web server or breaking updates. Here are the exact values that work in production:
# WordPress root directory
chmod 755 /var/www/html
# All files under WordPress
find /var/www/html -type f -exec chmod 644 {} ;
# All directories under WordPress
find /var/www/html -type d -exec chmod 755 {} ;
# wp-config.php gets the strictest permissions
chmod 640 /var/www/html/wp-config.php
# If wp-config is one directory up (recommended below)
chmod 640 /var/www/wp-config.php
# Uploads directory needs to be writable by web server
chmod 775 /var/www/html/wp-content/uploads
chown -R www-data:www-data /var/www/html/wp-content/uploads
The 640 on wp-config.php means owner read/write (6), group read (4), and no access for others (0). The web server must be in the group that owns wp-config for WordPress to function. Verify with ls -la wp-config.php β you should see -rw-r-----.
Move wp-config.php One Directory Up
WordPress automatically looks for wp-config.php one level above the document root if it cannot find it in the root directory. Take advantage of this:
# If WordPress is at /var/www/html/
mv /var/www/html/wp-config.php /var/www/wp-config.php
This places wp-config outside the web-accessible directory entirely. Even if PHP processing fails and the server starts serving raw files, wp-config is unreachable via HTTP. WordPress will detect and load it from the parent directory automatically β no configuration changes needed inside WordPress.
Harden wp-config.php with Specific define() Constants
Add these defines to wp-config.php above the /* That's all, stop editing! */ line:
/** Disable the plugin and theme file editor in admin */
define('DISALLOW_FILE_EDIT', true);
/** Disable plugin and theme installation/update entirely */
define('DISALLOW_FILE_MODS', false);
/** Force SSL on admin pages */
define('FORCE_SSL_ADMIN', true);
/** Limit post revisions stored in database */
define('WP_POST_REVISIONS', 5);
/** Set trash auto-empty to 7 days instead of 30 */
define('EMPTY_TRASH_DAYS', 7);
/** Disable automatic updates for core (manage manually or via CI/CD) */
define('WP_AUTO_UPDATE_CORE', false);
/** Require SSL for login */
define('FORCE_SSL_LOGIN', true);
DISALLOW_FILE_EDIT removes the Theme Editor and Plugin Editor from the admin menu. This stops attackers who gain admin access from injecting code directly through the browser. Set DISALLOW_FILE_MODS to true only if you want to block all plugin and theme installations β useful for locked-down client sites but prevents legitimate updates.
Generate and Rotate Security Keys
Replace the default authentication keys and salts. Use WordPress.org’s official generator:
# Visit https://api.wordpress.org/secret-key/1.1/salt/ in your browser
# Or generate from CLI:
curl -s https://api.wordpress.org/secret-key/1.1/salt/
Copy the output and replace the existing keys in wp-config.php. Rotate these keys every 6 months or immediately after any security incident. Rotating keys invalidates all existing login sessions and cookies β every user will need to log in again.
Change the Database Prefix
Do this during installation by changing the $table_prefix variable in wp-config.php. For an existing site, use the following SQL run against your database:
-- Rename all core tables
RENAME TABLE wp_posts TO x7_posts;
RENAME TABLE wp_users TO x7_users;
RENAME TABLE wp_usermeta TO x7_usermeta;
RENAME TABLE wp_options TO x7_options;
RENAME TABLE wp_postmeta TO x7_postmeta;
RENAME TABLE wp_terms TO x7_terms;
RENAME TABLE wp_term_taxonomy TO x7_term_taxonomy;
RENAME TABLE wp_term_relationships TO x7_term_relationships;
RENAME TABLE wp_comments TO x7_comments;
RENAME TABLE wp_commentmeta TO x7_commentmeta;
RENAME TABLE wp_links TO x7_links;
-- Update prefix in options table
UPDATE x7_options SET option_name = 'x7_user_roles' WHERE option_name = 'wp_user_roles';
-- Update prefix in usermeta table
UPDATE x7_usermeta SET meta_key = REPLACE(meta_key, 'wp_', 'x7_') WHERE meta_key LIKE 'wp_%';
Then update wp-config.php: $table_prefix = 'x7_';. Changing the prefix adds a minor obstacle to automated SQL injection scripts that assume default table names. It will not stop a determined attacker, but it breaks automated tools that rely on wp_ defaults.
Limit Login Attempts with Code
Add this to your custom plugin or mu-plugin file at wp-content/mu-plugins/login-limit.php:
<?php
add_filter('authenticate', function($user, $username, $password) {
$ip = $_SERVER['REMOTE_ADDR'];
$transient_key = 'login_attempts_' . md5($ip . $username);
$attempts = get_transient($transient_key);
if ($attempts && $attempts >= 5) {
wp_die('Too many failed login attempts. Try again in 30 minutes.');
}
return $user;
}, 30, 3);
add_action('wp_login_failed', function($username) {
$ip = $_SERVER['REMOTE_ADDR'];
$transient_key = 'login_attempts_' . md5($ip . $username);
$attempts = get_transient($transient_key) ?: 0;
set_transient($transient_key, $attempts + 1, 30 * MINUTE_IN_SECONDS);
});
This blocks by IP and username combination, not just IP. Attackers rotating usernames on the same IP still get caught. The 30-minute lockout period uses transients so it auto-expires without cron dependency. For production sites, supplement this with fail2ban at the server level.
Disable XML-RPC When You Do Not Need It
XML-RPC is enabled by default and exposes xmlrpc.php for pingbacks, trackbacks, and the WordPress mobile app. Most sites do not use it. Add to your plugin or functions.php:
// Disable XML-RPC entirely
add_filter('xmlrpc_enabled', '__return_false');
// Block access at server level β add to .htaccess
// <files xmlrpc.php>
// order allow,deny
// deny from all
// </files>
If you use Jetpack, the WordPress mobile app, or certain external publishing tools, you need XML-RPC. Test your workflow after disabling. For WooCommerce sites using the REST API exclusively, disable XML-RPC without hesitation.
Plugin Audit Criteria
Every plugin added to a production site should pass this evaluation. Do not install plugins that fail more than one criterion:
- Last update within 6 months: Check the “Last Updated” date on wordpress.org. Plugins dormant for over a year are liability risks.
- Compatible with current WordPress version: If the plugin author has not tested against the current major version, they are not maintaining it actively.
- Under 50,000 active installs unless critically necessary: Niche plugins have less scrutiny. If you need a niche plugin, read the full source code before installing.
- No premium upsell gating of basic security features: Avoid freemium plugins that disable standard WordPress capabilities behind paywalls β these often create their own security holes.
- Source code audit: Search the plugin source for
eval(),base64_decode(),exec(),shell_exec(), and direct SQL queries without$wpdb->prepare(). Any use of these requires justification. - Check for database writes on every page load: Use Query Monitor. If a plugin writes to the database on front-end requests, it will become a bottleneck under load.
.htaccess Rules for wp-includes and wp-content/uploads
Add this to the root .htaccess file, above the WordPress rewrite block:
# Block PHP execution in uploads directory
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^wp-admin/install.php$ - [F]
</IfModule>
# Deny access to sensitive files
<FilesMatch "^(wp-config.php|php.ini|.htaccess|.htpasswd)$">
Order deny,allow
Deny from all
</FilesMatch>
# Block PHP in uploads β create this in wp-content/uploads/.htaccess
# <Files *.php>
# deny from all
# </Files>
Create a separate .htaccess inside wp-content/uploads/ containing only the PHP deny block. This stops the most common attack vector: uploaded PHP shells executed through the uploads directory. For Nginx, add this to your server block:
location ~* /(?:uploads|files)/.*.php$ {
deny all;
}
Remove WordPress Version from Headers
Version disclosure helps attackers identify vulnerable sites. Add to your plugin:
// Remove WordPress version from head and RSS
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', '__return_empty_string');
// Remove version from scripts and styles
add_filter('style_loader_src', function($src) {
return remove_query_arg('ver', $src);
});
add_filter('script_loader_src', function($src) {
return remove_query_arg('ver', $src);
});
The version query string in asset URLs does not provide real security β anyone can check your WP version via readme.html or CSS class names. But removing it cleans up your markup and stops automated scanners that flag version strings as entry points.
Database User Least Privileges
Create a dedicated MySQL user for WordPress with the minimum required privileges. Do not use root or an all-privileges account:
-- Create a dedicated database and user
CREATE DATABASE wordpress_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_app'@'localhost' IDENTIFIED BY 'your-strong-password-here';
-- Grant only the privileges WordPress needs
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX ON wordpress_prod.* TO 'wp_app'@'localhost';
FLUSH PRIVILEGES;
WordPress needs CREATE and ALTER for plugin/theme installation and updates. DROP is required for uninstall routines. Remove CREATE, DROP, and ALTER from production sites that are fully locked down and managed through CI/CD deployments only. If you are not installing plugins on production, those privileges can be revoked:
REVOKE CREATE, DROP, ALTER ON wordpress_prod.* FROM 'wp_app'@'localhost';
This is the single most effective database-level protection. Even if an attacker gains SQL injection capability, they cannot create new tables, drop existing ones, or modify schema without these privileges.
Weekly Security Verification Routine
Add this checklist to your project documentation and run through it weekly:
- Run
find /var/www/html -type f -perm /o+wβ any result is a problem. - Check
wp-config.phppermissions: should be 640. - Review admin user list β unauthorized admins mean full compromise.
- Run
grep -r "eval(" wp-content/plugins/β investigate any hits. - Check error logs for repeated failed login attempts from single IPs.
- Verify no new files in
wp-content/uploads/with.phpextension. - Review
access.logfor requests toxmlrpc.phporwp-login.phpfrom unexpected countries.
Security is not a one-time plugin installation. It is a continuous process of hardening, monitoring, and responding. Apply every item on this list before your site goes live, and verify the checklist weekly after launch.