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.

With shell access, WP-CLI does the same from the site folder:

WP-CLI
wp core update
wp core update-db
Back to top

Update plugins and themes

Plugins and themes are the other common way in: most WordPress security advisories are about them.

WP-CLI
wp plugin update --all
wp theme update --all
Back to top

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.

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 top

Turn 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:

Apache (.htaccess)
<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>
nginx
# 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;
Back to top

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:

Apache (.htaccess)
<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>
nginx
# 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.

Back to top

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.

Back to top

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:

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:

certbot
# 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://:

Apache (.htaccess)
# .htaccess, above the # BEGIN WordPress block
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
nginx
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}
Back to top

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.

Apache (.htaccess)
<Files "xmlrpc.php">
  Require all denied
</Files>
nginx
location = /xmlrpc.php {
    deny all;
}

Security plugins (Wordfence, Solid Security and others) also have an option to disable XML-RPC.

Back to top

Remove 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):

PHP
// functions.php of a child theme, or a small custom plugin
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', '__return_empty_string');
Back to top

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):

Apache (.htaccess)
<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:

nginx
location = /readme.html {
    deny all;
}
Back to top

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:

PHP
// 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 top

Turn 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.

Apache (.htaccess)
Options -Indexes
nginx
# nginx lists directories only where autoindex is switched on: remove it, or set
autoindex off;
Back to top

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
// 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:

Apache (.htaccess)
<Files "debug.log">
  Require all denied
</Files>
nginx
location ~* /debug\.log$ {
    deny all;
}
Back to top

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.

Apache
# 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:

nginx
# 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
; 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:

Apache (.htaccess)
# .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>
Back to top

Add security headers

Three response headers close common browser-side attacks:

Apache (.htaccess)
<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>
nginx
# 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;
Back to top

Create your free account

Monitor up to 5 WordPress sites for free.

We will send you a confirmation email. By creating an account you accept the Terms of Service and the Privacy Policy.