Security Hardening

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 8.4 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.1 requires PHP 7.4 at minimum, and PHP 8.3 or newer is recommended. WordPress 7.2 is in beta and due in December 2026; nothing on this page is expected to change with it. Every command, request and header below was run against a WordPress 7.1.3 site (official wordpress:7.1.3-php8.4-apache image, MariaDB 11.8, table prefix lp_) and an nginx:1.29 container, and every output block is real output with the test host name and client addresses replaced by lumenpress.test. A disposable site for trying the commands is described in Local Development Environments.

This is the administrator’s view: what to configure on the server, in wp-config.php and in small must-use plugins so that a WordPress site resists the attacks it actually receives. Writing secure plugin and theme code (capabilities, nonces, sanitizing and escaping) is the developer’s side and is covered in Security for Developers. User accounts, roles and the user side of application passwords are in Users, Roles and Profiles.

The examples harden the production site of Lumen Press, the fictitious publisher used throughout this sub-section: nginx in front of PHP-FPM, a MariaDB database, the lumen block theme and the lumen-catalog plugin.

Threat model

Hardening only pays off when it is aimed at the attacks that happen. For WordPress sites they are, roughly in order of how often they lead to a compromise:

Threat How it works Main defenses

Vulnerable extensions

A plugin or theme with a known, unpatched vulnerability (XSS, SQL injection, broken access control, file upload); scanners try published exploits within hours of disclosure. This is the most common way in.

Few, maintained plugins; prompt updates; virtual patching in a WAF; vulnerability alerts

Brute force and credential stuffing

Bots try leaked or common passwords against wp-login.php and xmlrpc.php.

Unique passwords, two factors, rate limits at the edge or web server

Supply chain

A plugin changes owner and ships malicious code, an abandoned plugin is never fixed, a "nulled" theme carries a backdoor, or FTP/SSH credentials are stolen.

Trusted sources only, review ownership changes, integrity monitoring, SSH keys

Information disclosure and hosting

Usernames from the REST API, a public debug.log, backups or .git in the web root, outdated PHP, a database reachable from the internet.

Enumeration guard, server deny rules, supported PHP, private database network

WordPress core itself is rarely the entry point: it has a dedicated security team, and fixes are released as minor versions that install automatically and are backported to older branches. The weak points are the extensions and the accounts, so most of this page is about limiting what an attacker can do once one of them fails.

Core is not immune, though, which is why core security releases are applied the day they ship. WordPress 7.1.4 (2026-10-28) is a security-only release whose most serious fix closes a weakness in the upgrade process that could lead to remote code execution during the upgrade window; it also fixes stored XSS in crafted block markup, the Interactivity API, Customizer selective refresh, privacy data removal requests, user logins and the Categories, Playlist, Image and comments-pagination blocks, a stored denial of service in the Interactivity API, unauthenticated discovery of unpublished Customizer changesets, Notes readable by users who cannot edit the post, and it updates the bundled root certificates. The announcement asks site owners to update immediately; sites with automatic background updates receive it on their own (see Updates and Auto-Updates).

No single setting stops every attack, so Lumen Press applies controls in layers, each of which still helps when an outer one is bypassed: the edge (a CDN or web application firewall), the web server, WordPress and its extensions, the accounts, and the data. Monitoring and a rehearsed incident response cover all of them.

Layered defense for a WordPress site: edge, web server, WordPress, accounts and data, with monitoring across every layer

HTTPS everywhere

Login cookies and application passwords are bearer credentials, so every WordPress site needs HTTPS.

The site address must use HTTPS in Settings > General (the home and siteurl options). Since 5.7, Tools > Site Health detects when HTTPS is available but unused and offers Update your site to use HTTPS, which switches both options and rewrites old http:// links in content on the fly. With WP-CLI, wp search-replace rewrites them permanently, as described in Importing, Exporting and Migrating.

FORCE_SSL_ADMIN forces the dashboard and logins over HTTPS; it must be defined in wp-config.php, not in a plugin. The older FORCE_SSL_LOGIN was deprecated in 4.0. When TLS ends at a load balancer or CDN and the request reaches PHP over HTTP, WordPress believes it is on HTTP and loops redirecting to HTTPS. The fix is to trust the proxy’s X-Forwarded-Proto header, and only from that proxy:

// wp-config.php, above "That's all, stop editing!".
// Only when every request reaches PHP through a TLS-terminating proxy that sets X-Forwarded-Proto.
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && false !== strpos( $_SERVER['HTTP_X_FORWARDED_PROTO'], 'https' ) ) {
	$_SERVER['HTTPS'] = 'on';
}
define( 'FORCE_SSL_ADMIN', true );

The header-trust lines are only safe when PHP is reachable through the proxy alone. HSTS (Strict-Transport-Security) is sent by the web server, as shown in Security headers. TLS configuration itself (certificates, protocols, HTTP/2 and HTTP/3) is in Web Servers: Apache and nginx.

Passwords and bcrypt

Since 6.8, WordPress hashes user passwords with bcrypt through PHP’s password_hash(), replacing the portable phpass hashes used since 2.5. Because bcrypt only reads the first 72 bytes, WordPress first computes an HMAC-SHA384 of the password (keyed with wp-sha384) and hashes that; the stored value is marked with a $wp prefix, so user_pass starts with $wp$2y$ followed by the cost. Old phpass hashes ($P$) still validate and are replaced the next time the user logs in, so a site upgraded from an older version holds a mix until everyone has logged in once. Application passwords, which are long random strings, are hashed with the faster BLAKE2b instead ($generic$), as described in Users, Roles and Profiles.

On the Lumen Press test site, under PHP 8.4, every hash started with $wp$2y$12$ (the query is shown in Users, Roles and Profiles). That cost is PHP’s default, which PHP 8.4 raised from 10 to 12, so the same WordPress version produces cheaper hashes on PHP 8.3. Two filters control the algorithm and its options, and wp_password_needs_rehash() (6.8) compares a stored hash against them, so a change is applied gradually at each user’s next login. Lumen Press pins the cost in a must-use plugin so it does not depend on the PHP version:

<?php
/**
 * Plugin Name: Lumen Password Hashing
 * Description: Pins the bcrypt cost for user passwords, whatever PHP version the host runs.
 * Version:     0.1.0
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

add_filter(
	'wp_hash_password_options',
	static function ( $options, $algorithm ) {
		if ( PASSWORD_BCRYPT === $algorithm ) {
			$options['cost'] = 13; // PHP 8.4 defaults to 12, PHP 8.3 and older to 10.
		}
		return $options;
	},
	10,
	2
);

Saved as wp-content/mu-plugins/lumen-password-hashing.php, it made wp_password_needs_rehash() return true for the existing cost-12 hashes, and after tom.author logged in his hash started with $wp$2y$13$ while the others kept cost 12. Each step of cost doubles the time of every login, so it should be measured on the production hardware. The wp_hash_password_algorithm filter can switch to Argon2id (PASSWORD_ARGON2ID, when PHP was built with it); on the test site the new hashes started with $argon2id$v=19$m=65536,t=4,p=1 and the old bcrypt ones still validated (and were flagged for rehashing).

Core does not enforce password strength: it shows a strength meter and asks to confirm a weak password. Resetting a password with an MD5 hash written into the database still works once (it is rehashed at the next login), but wp user update <user> --user_pass=<password> is the clean route. Password policies, expiry and breached-password checks come from plugins or from single sign-on. The hashing functions themselves are explained in PHP: Cryptography, Hashing and Passwords.

Multi-factor authentication

WordPress core has no two-factor authentication; it is added with a plugin or by delegating logins to an identity provider. The reference implementation is Two Factor, maintained by WordPress.org contributors, which provides authenticator-app (TOTP) codes, email codes and backup codes, with passkeys and security keys through a companion WebAuthn provider plugin. Many security suites (listed in Security plugins) include their own two-factor module, and SSO through SAML or OpenID Connect moves the second factor to the identity provider.

wp plugin install two-factor --activate

Lumen Press requires a second factor for Administrators and Editors and enrolls two authenticators per administrator. A second factor protects the login form only; application passwords bypass it by design. The methods are explained in Authentication Methods: Passwordless and 2FA.

Application passwords

Application passwords (5.6+) authenticate REST API and XML-RPC requests with HTTP Basic authentication and never work on wp-login.php. They are shown once, stored hashed, listed per user and revocable one by one; creating, using and revoking them is covered in Users, Roles and Profiles. For hardening:

  • They require HTTPS unless WP_ENVIRONMENT_TYPE is local (wp_is_application_passwords_supported()).

  • A site with no API clients turns them off entirely with add_filter( 'wp_is_application_passwords_available', '__return_false' ); in a must-use plugin; per-user rules use the wp_is_application_passwords_available_for_user filter.

  • Some proxies and CGI setups strip the Authorization header; the Apache rules WordPress writes to .htaccess restore it with RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}].

  • An application password bypasses two factors and wp-login.php rate limits, so give it a dedicated low-role user.

Brute-force defense at the edge

Core does not limit login attempts. Every attempt costs a full PHP request and a bcrypt verification, so the cheapest place to stop a password-guessing bot is in front of PHP: a CDN or WAF rule, or the web server. A login-limiting plugin also works but still pays the PHP cost of every blocked request. The official guidance adds strong passwords, two factors, protecting or disabling XML-RPC, temporary IP bans from log monitoring (Fail2ban is the usual tool), and warns that permanent country blocklists and HTTP Basic authentication on the whole of /wp-admin are unreliable or break AJAX and REST calls from the front end.

Lumen Press throttles wp-login.php in nginx with limit_req: a burst of three attempts, then six per minute per client address, answered with status 429. The same server block denies XML-RPC, blocks PHP in uploads and dotfiles, and adds the security headers discussed below:

# /etc/nginx/conf.d/lumenpress.conf (included inside the http {} block)

limit_req_zone $binary_remote_addr zone=wp_login:10m rate=6r/m;
limit_req_status 429;

server {
    listen 443 ssl;
    http2 on;
    server_name lumenpress.test;
    root /var/www/lumenpress;
    index index.php;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    location = /wp-login.php {
        limit_req zone=wp_login burst=3 nodelay;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9000;
    }

    # XML-RPC is not used by Lumen Press.
    location = /xmlrpc.php {
        deny all;
    }

    location = /wp-config.php { deny all; }
    location ~ /\.(?!well-known/) { deny all; }
    location ~* ^/wp-content/uploads/.*\.(?:php[0-9]?|phtml|phar)$ { deny all; }

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9000;
    }
}

nginx -t accepts the file in nginx:1.29 (1.29.8). With the same rules in front of the test site, six failed logins in a row returned 200 200 200 200 429 429, xmlrpc.php, a PHP file in uploads and /.git/config returned 403, and the error log recorded the throttling:

nginx: configuration file /etc/nginx/nginx.conf test is successful
[error] 27#27: *9 limiting requests, excess: 3.678 by zone "wp_login", client: <ip>, server: lumenpress.test, request: "POST /wp-login.php HTTP/2.0", host: "lumenpress.test"

Points to get right:

  • The regular-expression locations are checked in file order and the first match wins, so the uploads and dotfile rules must come before location ~ \.php$.

  • add_header directives in server are not inherited by a location that has its own add_header.

  • Behind a CDN or load balancer, restore the client address with ngx_http_realip_module first, or every visitor shares the proxy’s bucket.

  • Apache has no limit_req; Apache sites rely on the edge, mod_security, Fail2ban or a plugin, or restrict wp-login.php to known networks with Require ip.

XML-RPC

xmlrpc.php is the remote publishing API that predates the REST API: older versions of the WordPress mobile apps used it, as do desktop blog editors, pingbacks and some services such as Jetpack. It accepts a username and password in every request body, so it is a second login form without the protections that plugins add to wp-login.php.

Its system.multicall method used to be a brute-force amplifier: one request could carry hundreds of password guesses. Since 4.4 the first failed login in a request makes every later call in it fail; on the test site a multicall with a wrong password followed by the right one returned two Incorrect username or password. faults. One guess per request remains, so XML-RPC is disabled when unused and rate limited like wp-login.php when needed.

The xmlrpc_enabled filter is misleadingly named: returning false only disables the methods that need authentication. pingback.ping and the system.* methods keep answering. Disabling everything needs the xmlrpc_methods filter as well:

<?php
/**
 * Plugin Name: Lumen XML-RPC Off
 * Description: Turns off every XML-RPC method and the pingback discovery headers on Lumen Press.
 * Version:     0.1.0
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

// Methods that need a login (wp.*, metaWeblog.*, blogger.*) answer "XML-RPC services are disabled".
add_filter( 'xmlrpc_enabled', '__return_false' );

// Remove every remaining method, including pingback.ping, so nothing is left to call.
add_filter( 'xmlrpc_methods', '__return_empty_array' );

// Stop advertising the endpoint: the X-Pingback header and the RSD link in <head>.
add_filter(
	'wp_headers',
	static function ( $headers ) {
		unset( $headers['X-Pingback'] );
		return $headers;
	}
);
remove_action( 'wp_head', 'rsd_link' );

The results on the test site, saved as wp-content/mu-plugins/lumen-xmlrpc-off.php:

Request Only xmlrpc_enabled Both filters

wp.getUsersBlogs (login)

Fault 405, XML-RPC services are disabled on this site.

Fault -32601, requested method wp.getUsersBlogs does not exist.

pingback.ping (production)

Processed (the server fetched the source URL)

Fault -32601, method does not exist

system.listMethods

80 methods

Only system.multicall, system.listMethods, system.getCapabilities

The three system.* methods are built into the XML-RPC server class and cannot be removed by the filter, which is why denying xmlrpc.php at the web server, as in the nginx block above, is the stronger option. On Apache 2.4 the equivalent is a <FilesMatch "^(xmlrpc\.php|wp-config\.php)$"> section with Require all denied, placed above the # BEGIN WordPress marker so WordPress never rewrites it; on the test site both files then answered 403.

Since 7.1, pingbacks and trackbacks (outgoing and incoming, including pingback.ping) are disabled automatically in every environment type other than production; the wp_should_disable_pings_for_environment filter overrides it. Before disabling XML-RPC, check for Jetpack, desktop editors and integrations that still need it.

A least-privilege database user

For day-to-day work (publishing, uploads, comments, new users, installing plugins), WordPress needs only SELECT, INSERT, UPDATE and DELETE on its own database. Schema changes during major updates, and plugins that create tables on activation, also need CREATE, ALTER, INDEX and DROP. The official handbook allows revoking the structural privileges but advises against it unless tested backups are in place, because a major update that cannot alter the schema fails half-way.

Lumen Press keeps two accounts: a runtime account for PHP with data privileges only, and a maintenance account that WP-CLI uses during updates and plugin activations. Neither has GRANT, FILE or privileges on other databases, and each site on the server has its own database and user:

-- Runtime account used by PHP: data only.
CREATE USER 'lumen_app'@'10.0.2.%' IDENTIFIED BY '<app-db-password>';
GRANT SELECT, INSERT, UPDATE, DELETE ON lumen_press.* TO 'lumen_app'@'10.0.2.%';

-- Maintenance account used by WP-CLI during updates and plugin activation.
CREATE USER 'lumen_maint'@'10.0.2.%' IDENTIFIED BY '<maint-db-password>';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP ON lumen_press.* TO 'lumen_maint'@'10.0.2.%';

wp-config.php reads the account from the environment, so the web server’s PHP-FPM pool gets the runtime account and the deployment shell exports the maintenance one:

// wp-config.php
define( 'DB_NAME', 'lumen_press' );
define( 'DB_USER', getenv( 'LP_DB_USER' ) ?: 'lumen_app' );
define( 'DB_PASSWORD', getenv( 'LP_DB_PASSWORD' ) ?: '' );
define( 'DB_HOST', getenv( 'LP_DB_HOST' ) ?: 'localhost' );

As lumen_app, wp option update, wp post list and the front page worked on the test site, while creating a plugin table failed with "CREATE command denied to user"; it succeeded as lumen_maint. Plugin activation and wp core update-db therefore run as the maintenance account, which suits sites deployed from a pipeline (see Deployment and CI/CD). The database listens only on a private network.

File editing and file modification constants

Two constants in wp-config.php remove the dashboard’s ability to change code, which is what an attacker with a stolen Administrator session would use first:

  • DISALLOW_FILE_EDIT removes the theme and plugin file editors and nothing else.

  • DISALLOW_FILE_MODS additionally blocks installing, updating and deleting plugins and themes and updating core from the dashboard, including background updates. It is meant for sites deployed from version control.

On the test site, DISALLOW_FILE_EDIT removed only edit_plugins, edit_themes and edit_files from the Administrator; adding DISALLOW_FILE_MODS also removed install_plugins, update_plugins, delete_plugins and update_core, while activate_plugins remained.

// wp-config.php on the Lumen Press production server.
define( 'DISALLOW_FILE_EDIT', true );
define( 'DISALLOW_FILE_MODS', true ); // Updates arrive through the deployment pipeline.
define( 'WP_ENVIRONMENT_TYPE', 'production' );

DISALLOW_FILE_MODS turns automatic security updates off as well, so a site that uses it needs its own process for applying them quickly (see Updates and Auto-Updates). Both constants are listed with the rest in wp-config.php Reference. They complement correct file ownership and permissions (644 for files, 755 for directories, 400 or 440 for wp-config.php, the web server writing only to wp-content/uploads where possible), which are explained in File Permissions and Filesystem. Uploads must never execute PHP: the nginx block above denies it, and on Apache a wp-content/uploads/.htaccess with a <FilesMatch "\.(?i:php[0-9]?|phtml|phar)$"> section containing Require all denied does the same (on the test site a PHP file in uploads went from 200 to 403).

Table prefix

The table prefix ($table_prefix, wp_ by default) is chosen at installation. A non-default prefix such as lp_ defeats some automated SQL injection payloads that hard-code the default table names; the handbook files it under "security through obscurity", since an injection can still read information_schema. Changing it later is risky for little gain: the <prefix>user_roles option and the <prefix>capabilities and <prefix>user_level user meta keys carry it too, and a missed one leaves every user without a role.

Secret keys and salts

wp-config.php defines eight secrets: AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY and their *_SALT counterparts. wp_salt() combines them into the keys that sign login cookies and nonces. If a constant is missing or still holds the placeholder text, WordPress generates a value and stores it in the options table, which is weaker because a database leak then exposes it too.

Changing them invalidates every login cookie (everyone, including attackers holding a stolen cookie, is logged out) and every outstanding nonce. Rotate them:

  • after a compromise or a suspected cookie theft, or when wp-config.php may have leaked (a backup in the web root, a public repository, a departing contractor),

  • not on a schedule: routine rotation only logs everyone out, and plugins that encrypt stored data with a key derived from the salts (two-factor secrets, API tokens) can lose access to it.

New values come from https://api.wordpress.org/secret-key/1.1/salt/ or from WP-CLI:

wp config shuffle-salts    # Success: Shuffled the salt keys.

On the test site, a logged-in Editor’s cookie loaded /wp-admin/profile.php with 200 before the command and was redirected to the login screen (302) after it. The session tokens stored in user meta survive, but no cookie matches them any more; they can be cleared per user with wp user session destroy <user> --all (WP-CLI 2.12.0 has no all-users flag, so a loop over wp user list --field=ID does it). shuffle-salts writes literal values into wp-config.php: on the official Docker image it replaced the getenv_docker('WORDPRESS_AUTH_KEY', ...) expressions, so sites that load secrets from the environment rotate them in the secret store instead.

Security headers

WordPress 7.1.3 already sends X-Frame-Options: SAMEORIGIN and Content-Security-Policy: frame-ancestors 'self'; (send_frame_options_header()) plus Referrer-Policy: strict-origin-when-cross-origin (wp_admin_headers(), 4.9+) on wp-login.php and admin screens, and X-Content-Type-Options: nosniff on REST and admin-ajax.php responses. Front-end pages get none of them.

The front end, static files and uploads get their headers from the web server (the nginx block above, or Header always set on Apache), which covers every response including images and PDFs. On hosts without access to the server configuration, a must-use plugin covers the WordPress-generated pages through the wp_headers filter:

<?php
/**
 * Plugin Name: Lumen Security Headers
 * Description: Sends baseline security headers on front-end pages when the web server cannot add them.
 * Version:     0.1.0
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

add_filter(
	'wp_headers',
	static function ( $headers ) {
		$headers['X-Content-Type-Options']  = 'nosniff';
		$headers['Referrer-Policy']         = 'strict-origin-when-cross-origin';
		$headers['X-Frame-Options']         = 'SAMEORIGIN';
		$headers['Content-Security-Policy'] = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
		$headers['Permissions-Policy']      = 'camera=(), microphone=(), geolocation=()';
		if ( is_ssl() ) {
			$headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
		}
		return $headers;
	}
);

On the test site the home page then carried all five headers.

Choosing and tuning the values:

  • One place only. With the plugin and an Apache Header always set X-Content-Type-Options both active (after a2enmod headers, which the official image does not enable), the test site sent the header twice.

  • HSTS: includeSubDomains only when every subdomain serves HTTPS, and preload only after reading the preload list’s requirements, because removal takes months.

  • Content Security Policy. The directives above leave scripts alone. Restricting scripts is hard on WordPress (the block editor and inline scripts need 'unsafe-inline' or nonces, which the wp_script_attributes and wp_inline_script_attributes filters, 5.7+, can add); start with Content-Security-Policy-Report-Only on the front end.

  • X-XSS-Protection is obsolete; modern browsers ignore it, and the OWASP cheat sheet recommends not setting it.

CORS headers for the REST API are a different topic, covered in CORS.

User enumeration

A login name is half of a credential, and WordPress makes it easy to collect. The table shows what an anonymous visitor got from the test site, without and with the must-use plugin below:

Request Default answer With the guard

/?author=1

301 to /author/lp_admin/: the user_nicename, which equals the login unless changed

404

/wp-json/wp/v2/users

[{"id":1,"name":"lp_admin","slug":"lp_admin"},{"id":3,"name":"Tom Author","slug":"tom-author"}]: every user with a published post of a public, REST-enabled type

404 rest_no_route (200 with an application password)

/wp-sitemap-users-1.xml

The same users' archive URLs (core sitemaps, 5.5+)

404, and no users entry in wp-sitemap.xml

/wp-json/oembed/1.0/embed?url=<post URL>

"author_name":"Tom Author" and "author_url"

Both fields removed

wp-login.php errors

The username nosuch is not registered on this site. versus The password you entered for the username tom.author is incorrect.

Error: The username or password is incorrect. in both cases

Usernames are not secrets, and hiding them does not replace strong passwords, two factors and rate limits. Reducing the leak still removes the easy half of credential stuffing and keeps staff logins out of public lists. Lumen Press uses this must-use plugin:

<?php
/**
 * Plugin Name: Lumen User Enumeration Guard
 * Description: Keeps user logins and IDs away from anonymous visitors: ?author=N, the REST users route, the users sitemap, oEmbed author data and login error details.
 * Version:     0.1.0
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

// 1. "/?author=1" normally answers 301 to /author/<user_nicename>/. Answer 404 instead.
add_action(
	'template_redirect',
	static function () {
		if ( is_user_logged_in() || ! isset( $_GET['author'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return;
		}
		global $wp_query;
		$wp_query->set_404();
		status_header( 404 );
		nocache_headers();
	},
	1 // Before redirect_canonical(), which runs at priority 10.
);

// 2. Remove /wp/v2/users and /wp/v2/users/<id> for requests without a logged-in user.
add_filter(
	'rest_endpoints',
	static function ( $endpoints ) {
		if ( ! is_user_logged_in() ) {
			unset( $endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
		}
		return $endpoints;
	}
);

// 3. Drop the users provider from the core sitemap (wp-sitemap-users-1.xml).
add_filter(
	'wp_sitemaps_add_provider',
	static function ( $provider, $name ) {
		return 'users' === $name ? false : $provider;
	},
	10,
	2
);

// 4. Keep the author's name and archive URL out of oEmbed responses.
add_filter(
	'oembed_response_data',
	static function ( $data ) {
		unset( $data['author_name'], $data['author_url'] );
		return $data;
	}
);

// 5. One message for a wrong username and a wrong password.
add_filter(
	'login_errors',
	static function () {
		return '<strong>Error:</strong> The username or password is incorrect.';
	}
);

The plugin is saved as wp-content/mu-plugins/lumen-user-privacy.php. What the guard does not hide, by design:

  • The author archive itself (/author/tom-author/) still answers 200, since author pages are content. Give staff a user_nicename different from their login (wp user update tom.author --user_nicename=tom-author) and a display name that is not the login.

  • Logged-in users, including Subscribers, still see the REST users route (the block editor needs it); on sites with open registration, check a capability such as edit_posts instead of is_user_logged_in().

  • The lost-password and registration forms still reveal whether an account exists; rate limiting them matters more.

Security plugins

Security plugins bundle several of the controls on this page with others that need a service behind them. The categories, rather than the brands, decide what a site needs:

Category What it does, and where it runs

Web application firewall

Blocks exploit payloads: an endpoint WAF inside PHP (via auto_prepend_file or a must-use plugin) sees WordPress-aware requests; a cloud WAF stops traffic before it reaches the server and absorbs floods.

Vulnerability monitoring

Compares installed versions with a vulnerability database, alerts, or patches virtually until the update.

Malware and integrity scanning

Compares files with the official versions and looks for known malicious code.

Login protection and activity logs

Limits attempts, adds two factors or CAPTCHAs, and records who changed what for audits.

Widely used plugins on WordPress.org, in alphabetical order, include Kadence Security (the plugin previously published as iThemes Security and, from version 9.0, Solid Security; renamed again in version 10.0), Limit Login Attempts Reloaded, Sucuri Security, Two Factor, Wordfence Security and WP Activity Log; several have paid tiers that add cloud firewalls, real-time rules or cleanup services. Vulnerability databases (Wordfence Intelligence, Patchstack, WPScan) also offer feeds and integrations independently of any plugin.

Choosing among them follows Choosing and Managing Plugins and Themes; a security plugin is itself code with vulnerabilities, and it adds a layer rather than replacing updates, backups or server rules.

Monitoring

The handbook’s monitoring page covers logs, uptime and performance; for security, Lumen Press adds:

  • Logs. Web server access and error logs, PHP-FPM slow logs and the WAF log, kept off the server for at least 90 days. Failed logins and 429 responses on wp-login.php are the first sign of a campaign.

  • File integrity. WP-CLI compares core and WordPress.org plugins with their official checksums. Run nightly, it catches modified core files and files that should not be there:

wp core verify-checksums
wp plugin verify-checksums --all
Warning: File doesn't verify against checksum: wp-includes/version.php
Warning: File should not exist: wp-config-docker.php
Warning: File should not exist: wp-includes/class-wp-cache-helper.php
Error: WordPress installation doesn't verify against checksums.
Success: Verified 2 of 2 plugins.

This is the test site after one core file was modified and one added (wp-config-docker.php comes with the Docker image). Extra files only produce warnings, so read them rather than the exit code. Themes and premium plugins have no published checksums; compare them with version control.

  • Inventory. A scheduled job alerts when the Administrators, must-use plugins or drop-ins differ from the expected list, and Google Search Console reports injected spam and malware.

  • Site Health flags outdated PHP, a public debug.log and disabled updates; see Debugging, Site Health and Troubleshooting.

Incident response and cleaning a hacked site

Signs of a compromise include browser or search warnings, a suspended account, unknown Administrators, spam pages, redirects only some visitors see, and unexpected outgoing mail. Document symptoms and times first, then:

  1. Contain. Take the site offline at the edge; rotate salts, destroy sessions, revoke application passwords and keys.

  2. Preserve evidence. Copy files, a database dump and logs before cleaning; they show how the attacker got in.

  3. Identify. Run the integrity checks above and review modified files, PHP in uploads, users, must-use plugins, drop-ins, every .htaccess, cron events (wp cron event list) and the siteurl and home options:

    find wp-content -type f -name '*.php' -newer wp-config.php -print   # changed after wp-config.php
    find wp-content/uploads -type f -name '*.php' -print
    wp user list --role=administrator --fields=ID,user_login,user_registered
    wp plugin list --status=must-use,dropin --fields=name,status
  4. Clean. Restore from the last clean backup when one exists and its date is earlier than the intrusion. Otherwise reinstall rather than edit: delete wp-admin and wp-includes and download the same core version, then reinstall every plugin and theme from its official source or repository. Reinstalling over the existing files is not enough; on the test site wp core download --version=7.1.3 --skip-content --force restored the modified version.php but left the added file in wp-includes in place. Remove unknown users, rewrite wp-config.php from a known template, and check the database for injected scripts in posts, widgets and options.

  5. Rotate everything again once clean: all user passwords, salts, the database password, SSH keys, hosting and CDN accounts, and API keys stored in options.

  6. Update, harden and recover. Close the hole that was used, request a review in Google Search Console if the site was flagged, and watch the logs for weeks; attackers often leave more than one backdoor. Record the entry point and change the process that allowed it.

Backups that make the clean-up possible are covered in Backups and Disaster Recovery.

What not to do

Some popular advice adds work without adding security, or weakens it:

  • "Encrypting" values in wp-config.php. PHP must decrypt them on every request, so the key sits next to the ciphertext and an attacker who can read the file can read both. Protect the file with permissions, keep it outside the web root if wanted (WordPress finds it one directory up automatically, without changing ABSPATH; the benefit is disputed), or load secrets from the environment.

  • Hiding the login URL. Renaming wp-login.php with a plugin reduces log noise but is not a defense: the login form is still there, XML-RPC and the REST API still accept credentials, and the plugin can break other plugins and lock administrators out. Rate limits and two factors protect the form wherever it is.

  • Removing the generator tag as protection. The version still shows in asset URLs and feeds; updating is the fix.

  • Turning off updates (AUTOMATIC_UPDATER_DISABLED) without a replacement process.

  • Stacking security plugins. Two firewalls or login limiters in PHP conflict and slow the site.

Where older books are outdated

Older claim Current behaviour (WordPress 7.1)

WordPress core provides two-factor authentication

Core has none; it comes from a plugin such as Two Factor or from SSO

Passwords are stored as phpass (MD5-based) hashes

bcrypt since 6.8 ($wp$2y$ prefix), with rehashing at the next login; application passwords use BLAKE2b

Writable folders need permissions 666 or 777

644 for files and 755 for directories; wp-config.php 400 or 440

DISALLOW_FILE_MODS only disables updates

It also blocks installing and deleting plugins and themes and the file editors

Deny rules use Order allow,deny and Deny from all

Apache 2.4 uses Require all denied; the old directives need mod_access_compat

WP_ALLOW_REPAIR helps reset a lost password

It only repairs tables and exposes the repair page without login

system.multicall lets one request test hundreds of passwords

Since 4.4, the first failed login in a request makes the rest fail; the endpoint is still worth disabling when unused

iThemes Security

Renamed Solid Security (version 9.0) and then Kadence Security (version 10.0); same WordPress.org plugin

Further corrections across the whole sub-section are collected in Where Older Books and Tutorials Are Outdated.