How to fix
Step-by-step fixes for the problems WP Monitor reports. Take a full backup (files and database) before you change anything. Server rules are given for Apache (in the .htaccess file in the site root; LiteSpeed reads the same file) and for nginx (in the site configuration; reload nginx afterwards).
Update WordPress
Outdated WordPress is the most common way sites get hacked. Security fixes are only published for the current version, so a version with a known security hole should be updated the same day.
- In the admin area, go to Dashboard > Updates and click "Update to version ...".
- Then check the site: homepage, a few pages, the contact form, and the shop checkout if you have one.
- Leave automatic minor updates on (the default). They install security releases automatically.
With shell access, WP-CLI does the same from the site folder:
wp core update
wp core update-db
Update plugins and themes
Plugins and themes are the other common way in: most WordPress security advisories are about them.
- Go to Plugins > Installed Plugins, and to Appearance > Themes, and update everything that has an update.
- Delete plugins and themes you do not use. A deactivated plugin is still on the server, and its files can still be attacked.
- Premium plugins and themes update only with a valid license. If yours has expired, renew it or replace the plugin.
- Consider automatic updates for plugins you trust ("Enable auto-updates" in the plugin list).
wp plugin update --all
wp theme update --all
Enable a page cache
Without a page cache, WordPress builds every page from PHP and the database on each visit. A page cache stores the finished page and serves it directly, which is usually several times faster and handles traffic spikes much better.
- WP Rocket (paid): works on any server, with good defaults.
- W3 Total Cache (free): many options; choose "Disk: Enhanced" page caching.
- WP-Optimize (free): page cache plus database clean-up, simple to set up.
- If your hosting runs the LiteSpeed web server, use LiteSpeed Cache instead: it uses the server's built-in cache. On Apache or nginx it is limited, so pick one of the plugins above there.
Use only one page cache plugin at a time. Some hosts (SiteGround, Kinsta, WP Engine and others) already cache pages on the server; then no plugin is needed for this.
Back to topTurn on compression
Compression (Gzip or Brotli) makes pages, CSS and JavaScript 60-80% smaller on the way to the visitor. The cache plugins above can turn it on, or it can be set in the server configuration:
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml
AddOutputFilterByType DEFLATE application/javascript text/javascript application/json
AddOutputFilterByType DEFLATE application/xml application/rss+xml image/svg+xml
</IfModule>
# in the http { } or server { } block
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types text/plain text/css text/xml application/javascript text/javascript
application/json application/xml application/rss+xml image/svg+xml;
Let browsers cache CSS and JavaScript
When CSS, JavaScript, images and fonts are sent with a long cache lifetime, returning visitors and every next page load them from the browser instead of downloading them again. WordPress adds a version to these files (?ver=...) that changes when they change, so a long lifetime is safe.
Most cache plugins have a "browser caching" option. Or set it on the server:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 month"
ExpiresByType text/javascript "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
# in the server { } block
location ~* \.(?:css|js|webp|jpe?g|png|gif|svg|ico|woff2?)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
nginx: a location block with its own add_header lines no longer inherits the add_header lines from the server block, so repeat any security headers there if you add headers to it.
Make the first response faster
This is the time before the page starts arriving, measured from our server. Over 1.5 seconds usually means the page is built from scratch on every visit.
- Enable a page cache (see above). This usually fixes it on its own.
- If a cache is already active, look for a slow plugin: a plugin like Query Monitor shows which ones make pages slow.
- Use a current PHP version (8.2 or newer), with OPcache on.
- If none of this helps, the hosting plan may be too small for the site.
Fix the SSL certificate
When the certificate is not valid for the address, visitors get a full-page browser warning ("Your connection is not private") instead of the site, search engines drop it, and forms and logins are not safe. What to do depends on what we found:
- Certificate for another name: the server answers with a certificate for other names, often the hosting company's default one. The site has no certificate of its own on that server: add one for both example.com and www.example.com.
- Expired: renew it. Free Let's Encrypt certificates last 90 days and must renew automatically; if one expired, the automatic renewal is broken.
- Self-signed or untrusted: browsers only trust certificates from a public authority. Replace it with a Let's Encrypt (free) or commercial certificate, and install the full chain (the "CA bundle" or "fullchain" file), not only the certificate itself.
- No HTTPS at all: install a certificate, then send http:// visitors to https://.
On shared hosting use the control panel: AutoSSL in cPanel, "SSL It!" or Let's Encrypt in Plesk, or ask the host. On your own server, certbot gets and renews Let's Encrypt certificates:
# free Let's Encrypt certificate, renewed automatically (run as root)
certbot --nginx -d example.com -d www.example.com # nginx
certbot --apache -d example.com -d www.example.com # Apache
Once HTTPS works, redirect plain http:// to https://:
# .htaccess, above the # BEGIN WordPress block
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
- Check: open the site in a private browser window with and without www. There must be a padlock and no warning.
Disable XML-RPC
xmlrpc.php is an old remote access interface. Attackers use it to try hundreds of passwords in one request. Most sites do not need it anymore; the Jetpack plugin and some remote publishing apps do, so skip this if you use those.
<Files "xmlrpc.php">
Require all denied
</Files>
location = /xmlrpc.php {
deny all;
}
Security plugins (Wordfence, Solid Security and others) also have an option to disable XML-RPC.
Back to topRemove the WordPress generator tag
By default WordPress writes its exact version into every page: <meta name="generator" content="WordPress 7.1.2">. That tells attackers which known security holes to try.
Remove it with this code, or with the "hide WordPress version" option of a security plugin (Wordfence, Solid Security and others have one):
// functions.php of a child theme, or a small custom plugin
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', '__return_empty_string');
- Put the code in the
functions.phpof a child theme, or in a small custom plugin. Not in the main theme: a theme update would remove it. - Clear the page cache afterwards, otherwise visitors still get the old pages with the tag.
- Check: open the homepage, view the page source (Ctrl+U) and search for "generator". It must not show "WordPress" anymore.
Block access to readme.html
Every WordPress install has a readme.html file in the site root, and it shows the WordPress version to anyone who opens it.
Deleting it does not last: WordPress puts it back with every core update. Block access to it on the server instead; the rule stays in place after updates.
Apache and LiteSpeed: add this to the .htaccess file in the site root (the folder with wp-config.php), above or below the # BEGIN WordPress block, not inside it (WordPress rewrites that block):
<Files "readme.html">
Require all denied
</Files>
nginx: add this inside the server { } block of the site, then check and reload the configuration (nginx -t, then systemctl reload nginx). An exact location = match takes priority over the other rules:
location = /readme.html {
deny all;
}
- Check: open
https://your-site/readme.html. You should get "403 Forbidden" instead of the WordPress readme page. - On shared hosting without access to the server configuration, use the
.htaccessrule; the file manager of cPanel or Plesk can edit it (enable "show hidden files").
Stop listing usernames
By default, /wp-json/wp/v2/users lists the usernames of everyone who has published content. That hands attackers half of every login. This code hides the list from visitors who are not logged in, while the block editor keeps working:
// functions.php of a child theme, or a small custom plugin
add_filter('rest_endpoints', function ($endpoints) {
if (!is_user_logged_in()) {
unset($endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)']);
}
return $endpoints;
});
Most security plugins have a similar "disable user enumeration" option.
Back to topTurn off directory listing
With directory listing on, anyone can browse the list of files in a folder such as /wp-content/uploads/, including private uploads and backups.
Options -Indexes
# nginx lists directories only where autoindex is switched on: remove it, or set
autoindex off;
Remove the public debug log
wp-content/debug.log can contain server paths, plugin errors and sometimes database details, and anyone can download it. Delete the file now, then stop WordPress from writing it there:
// wp-config.php
define('WP_DEBUG', false);
define('WP_DEBUG_LOG', false);
// or, to keep logging, write the log outside the website folder:
// define('WP_DEBUG_LOG', '/home/USER/logs/wp-debug.log');
And block the file name, in case logging is switched on again later:
<Files "debug.log">
Require all denied
</Files>
location ~* /debug\.log$ {
deny all;
}
Hide the Apache, nginx and PHP version
Response headers like Server: nginx/1.30.5 or X-Powered-By: PHP/8.2.12 tell attackers exactly which versions to look up known security holes for. The server name alone (nginx, Apache) is fine; the version number is what to hide. This does not replace updating, it just stops advertising the version.
Apache: in the main server configuration (it cannot be set in .htaccess). On shared hosting ask your host; most already do this.
# main Apache configuration (not .htaccess), e.g. /etc/apache2/conf-enabled/security.conf
# or /etc/httpd/conf/httpd.conf, then reload Apache
ServerTokens Prod
ServerSignature Off
nginx: in the http { } block. fastcgi_hide_header also removes the PHP version header:
# in the http { } block of nginx.conf, then: nginx -t && systemctl reload nginx
server_tokens off;
# hides the PHP version header coming from PHP-FPM
fastcgi_hide_header X-Powered-By;
PHP: stop PHP from adding X-Powered-By at all. expose_php can only be set in php.ini or the PHP-FPM pool, not in .user.ini:
; php.ini (or the PHP-FPM pool: php_admin_flag[expose_php] = off), then restart PHP-FPM
expose_php = Off
Without access to php.ini (shared hosting on Apache or LiteSpeed), remove the header in .htaccess instead:
# .htaccess: removes the PHP version header when you cannot change php.ini
<IfModule mod_headers.c>
Header always unset X-Powered-By
Header unset X-Powered-By
</IfModule>
- Check:
curl -I https://your-site/should showServer: nginxorServer: Apachewithout a number, and noX-Powered-By: PHP/...line.
Add security headers
Three response headers close common browser-side attacks:
Strict-Transport-Security(HSTS): browsers always use HTTPS for your site, even if someone types or links http://. Add it only once HTTPS works everywhere on the site.X-Frame-Options: other sites cannot show your pages inside a frame (clickjacking).X-Content-Type-Options: browsers do not guess file types, which blocks some script injection tricks.
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
</IfModule>
# in the server { } block that serves HTTPS
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;