Why Out-of-the-Box WordPress Is Completely Insecure
Table of Contents
A fresh WordPress install ships with a collection of default settings, exposed endpoints, and dangerous features that make it trivially easy to enumerate users, brute-force credentials, and fingerprint your entire stack — all before an attacker writes a single line of exploit code.
01. The Default Problem — Why WordPress Ships Insecure
WordPress powers over 43% of all websites on the internet. That staggering market share makes it the single most-targeted CMS platform on the planet. Yet a brand-new installation — downloaded straight from WordPress.org, no plugins, no themes beyond Twenty Twenty-Four, fresh database — is riddled with security anti-patterns that violate every OWASP guideline in the book.
This is not a criticism of WordPress as a development project. Default-open configurations are a deliberate UX tradeoff: the team prioritises ease of installation over hardened defaults. The problem is that the vast majority of site owners never revisit those defaults. The result is millions of websites permanently running in their most vulnerable state.
A default WordPress install is not a starting point that is “secure enough for now.” It is an actively dangerous configuration that must be hardened before any content is published or any users are created.
Below is a map of every default weakness we will cover, along with its severity rating:
XML-RPC Brute Force
multicall allows thousands of password attempts in a single HTTP request, bypassing rate limiting entirely.
User Enumeration
Author archive URLs and the REST API expose every username on the site to unauthenticated visitors.
REST API Data Leakage
Unauthenticated /wp-json/wp/v2/users returns usernames, slugs, and Gravatar hashes by default.
wp-config.php Exposure
Default placement makes database credentials, secret keys, and salts potentially reachable from the web root.
Error Disclosure
WP_DEBUG and PHP display_errors reveal file paths, DB schema, and plugin internals to end users.
Weak Default Prefix
The “wp_” table prefix makes SQL injection payloads trivially predictable and portable across targets.
Directory Listing
Without an index.php in every upload subdirectory, Apache/Nginx serves a browsable directory index.
Version Fingerprinting
Generator meta tags, readme.html, and /feed expose the exact WordPress version to automated scanners.
02. User Enumeration: Your Usernames Are Public
WordPress maps each user to a numeric ID. By default, visiting /?author=1 redirects to /author/admin/, instantly revealing the administrator’s login username. Combine this with the REST API and an attacker has a complete user list in seconds.
Attack Vector
| URL Pattern | What It Reveals | Auth Required | Severity |
|---|---|---|---|
/?author=1 |
Administrator login slug via 301 redirect | None | Critical |
/wp-json/wp/v2/users |
All users: ID, name, slug, avatar URL | None | Critical |
/wp-json/wp/v2/users/1 |
Single user data including Gravatar hash | None | High |
/author/admin/ |
Confirms username; lists all posts | None | High |
Fix: Block Author Enumeration via functions.php
<?php
/**
* Prevent author enumeration via query string and redirect loop.
*
* Hooked early on 'template_redirect' to stop WordPress before
* it performs any author-archive query that leaks login slugs.
*
* @return void
*/
function oda_prevent_author_enumeration(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! \is_admin() && isset( $_GET['author'] ) ) {
\wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
}
\add_action( 'template_redirect', 'oda_prevent_author_enumeration', 1 );Fix: Remove Users from the REST API
<?php
/**
* Remove the 'author' field and restrict the /wp/v2/users endpoint
* to authenticated requests with 'list_users' capability.
*
* @param array $args Endpoint registration args.
* @param string $route The REST route being registered.
* @param WP_REST_Request $request The current request (unused here).
* @return array Modified args.
*/
function oda_restrict_rest_user_endpoint( array $args, string $route ): array {
if ( false !== strpos( $route, '/wp/v2/users' ) ) {
$args['permission_callback'] = static function (): bool {
return current_user_can( 'list_users' );
};
}
return $args;
}
\add_filter( 'rest_endpoints', 'oda_restrict_rest_user_endpoint', 10, 2 );
/**
* Strip the 'author_name' field from post response objects so that
* authenticated reads still cannot be used to harvest usernames.
*
* @param WP_REST_Response $response The REST response.
* @param WP_Post $post The post object.
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
function oda_remove_author_from_rest_posts(
WP_REST_Response $response,
WP_Post $post, // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
WP_REST_Request $request // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
): WP_REST_Response {
$data = $response->get_data();
unset( $data['author'] );
$response->set_data( $data );
return $response;
}
\add_filter( 'rest_prepare_post', 'oda_remove_author_from_rest_posts', 10, 3 );Removing the author endpoint from the REST API does not affect the front-end author archive. Make sure to also disable or noindex author archives in your SEO plugin if they serve no editorial purpose.
03. XML-RPC: A Built-In Brute-Force Gateway
xmlrpc.php was designed for remote publishing tools. In 2025 it is almost universally exploited. Its system.multicall method allows an attacker to bundle hundreds or thousands of wp.getUsersBlogs credential tests inside a single HTTP request, completely evading IP-based rate limits and lockout plugins.
A single POST to
/xmlrpc.php containing 1 000 nested wp.getUsersBlogs calls can test an entire rockyou.txt wordlist segment in under 60 seconds, saturate server CPU, and trigger zero alerts on most firewall configs because it looks like one request.What a multicall Attack Looks Like
<?xml version="1.0"?>
<methodCall>
<methodName>system.multicall</methodName>
<value>
<array>
<data>
<value>
<struct>
<member>
<name>methodName</name>
<value><string>wp.getUsersBlogs</string></value>
</member>
<member>
<name>params</name>
<value>
<array>
<data>
<value><string>admin</string></value>
<value><string>password123</string></value>
</data>
</array>
</value>
</member>
</struct>
</value>
<!-- ...repeated 999 more times with different passwords... -->
</data>
</array>
</value>
</param>
</params>
</methodCall>Fix 1: Disable XML-RPC Entirely (Recommended)
<?php
/**
* Disable the XML-RPC endpoint entirely.
*
* This is safe for the vast majority of sites. Only re-enable if you
* actively use a remote publishing client that requires it (e.g. Jetpack
* ping-backs, legacy mobile apps). Prefer the REST API for modern integrations.
*
* @return false
*/
\add_filter( 'xmlrpc_enabled', '__return_false' );
/**
* Also block xmlrpc.php at the PHP level as a defence-in-depth measure,
* because the 'xmlrpc_enabled' filter only fires after WordPress loads.
* Put the block below in your .htaccess (Apache) or server block (Nginx).
*/Fix 2: Block at the Web-Server Level
# Apache — add inside your VirtualHost or .htaccess
<Files "xmlrpc.php">
Order Allow,Deny
Deny from all
Satisfy All
</Files># Nginx — add inside your server {} block
location = /xmlrpc.php {
deny all;
access_log off;
log_not_found off;
}Fix 3: If You Must Keep XML-RPC, Remove Multicall
<?php
/**
* Remove the system.multicall and system.listMethods XML-RPC methods
* to prevent credential-stuffing amplification attacks while still
* allowing legitimate single-method remote publishing calls.
*
* @param array $methods Registered XML-RPC methods.
* @return array Filtered method list.
*/
function oda_remove_xmlrpc_multicall( array $methods ): array {
unset(
$methods['system.multicall'],
$methods['system.listMethods'],
$methods['system.getCapabilities']
);
return $methods;
}
\add_filter( 'xmlrpc_methods', 'oda_remove_xmlrpc_multicall' );04. REST API Data Leakage
The WordPress REST API, introduced in core in 4.7, is a powerful feature — and a major default information-disclosure vulnerability. Without any configuration, /wp-json/wp/v2/users returns a JSON array containing every registered user’s login slug, display name, Gravatar URL (from which an email hash can be extracted), and user ID.
What Attackers Extract From /wp/v2/users
[
{
"id": 1,
"name": "Site Administrator",
"url": "https://example.com",
"description": "",
"link": "https://example.com/author/admin/",
"slug": "admin",
"avatar_urls": {
"24": "https://secure.gravatar.com/avatar/abc123?s=24&d=mm&r=g",
"48": "https://secure.gravatar.com/avatar/abc123?s=48&d=mm&r=g",
"96": "https://secure.gravatar.com/avatar/abc123?s=96&d=mm&r=g"
},
"_links": {
"self": [ { "href": "https://example.com/wp-json/wp/v2/users/1" } ],
"collection": [ { "href": "https://example.com/wp-json/wp/v2/users" } ]
}
}
]The Gravatar URL contains an MD5 hash of the user’s email address. While MD5 is weak, this hash enables email verification using rainbow tables, facilitating phishing and credential-stuffing against the email address.
Restrict the Entire REST API to Logged-In Users (Aggressive)
<?php
/**
* Require authentication for all REST API requests.
*
* This approach is appropriate for non-public REST usage (e.g. headless/SPA
* backends where the front-end handles auth). Do NOT use this if your theme
* or plugins depend on unauthenticated REST calls for public content delivery.
*
* @param WP_Error|null|true $result Current authentication result.
* @return WP_Error|null|true
*/
function oda_rest_require_authentication( $result ) {
if ( null !== $result ) {
// Another authentication handler already ran.
return $result;
}
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_not_logged_in',
__( 'You must be authenticated to access the REST API.', 'oda-security' ),
array( 'status' => rest_authorization_required_code() )
);
}
return $result;
}
\add_filter( 'rest_authentication_errors', 'oda_rest_require_authentication' );Selective Approach: Disable Only the Users Endpoint
<?php
/**
* Unregister the /wp/v2/users endpoint for unauthenticated requests.
*
* Prefer this over blanket REST disabling when your site uses the REST API
* for other public data (e.g. posts, categories, custom post types).
*
* @param array $endpoints Registered REST endpoints.
* @return array
*/
function oda_remove_rest_users_endpoint( array $endpoints ): array {
$restricted = array(
'/wp/v2/users',
'/wp/v2/users/(?P<id>[\d]+)',
);
foreach ( $restricted as $route ) {
if ( isset( $endpoints[ $route ] ) && ! current_user_can( 'list_users' ) ) {
unset( $endpoints[ $route ] );
}
}
return $endpoints;
}
\add_filter( 'rest_endpoints', 'oda_remove_rest_users_endpoint' );05. wp-config.php — Secrets in the Wrong Place
wp-config.php lives in the web root by default. It contains your database host, database name, username, password, authentication keys, salts, and debug flags — all in plaintext. A misconfigured PHP handler, a server restart that briefly serves .php files as plain text, or a path traversal vulnerability in any plugin can expose every secret on your site in one request.
Move wp-config.php One Level Above the Web Root
# Move wp-config.php above the public web root.
# Replace /var/www/html with your actual web root path.
mv /var/www/html/wp-config.php /var/www/wp-config.php
# WordPress automatically looks one directory up, so no code changes needed.
# Verify the web server cannot serve it directly:
curl -I https://example.com/wp-config.php
# Expected: 403 Forbidden or 404 Not Found — never 200 OKHarden wp-config.php Settings
<?php
/**
* wp-config.php — hardened configuration template.
*
* @package WordPress
*/
// ── Database credentials ──────────────────────────────────────────────────────
define( 'DB_NAME', getenv( 'WP_DB_NAME' ) ?: 'your_db_name' );
define( 'DB_USER', getenv( 'WP_DB_USER' ) ?: 'your_db_user' );
define( 'DB_PASSWORD', getenv( 'WP_DB_PASSWORD' ) ?: 'your_db_password' );
define( 'DB_HOST', getenv( 'WP_DB_HOST' ) ?: '127.0.0.1' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', 'utf8mb4_unicode_520_ci' );
// ── Unique keys and salts — ALWAYS generate fresh ones ───────────────────────
// Generate at: https://api.wordpress.org/secret-key/1.1/salt/
define( 'AUTH_KEY', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'SECURE_AUTH_KEY', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'LOGGED_IN_KEY', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'NONCE_KEY', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'AUTH_SALT', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'SECURE_AUTH_SALT', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'LOGGED_IN_SALT', 'REPLACE_WITH_UNIQUE_VALUE' );
define( 'NONCE_SALT', 'REPLACE_WITH_UNIQUE_VALUE' );
// ── Custom database table prefix (see §07) ────────────────────────────────────
$table_prefix = 'zx9k_'; // Never use the default 'wp_'
// ── Security constants ────────────────────────────────────────────────────────
define( 'DISALLOW_FILE_EDIT', true ); // Block theme/plugin editor in admin
define( 'DISALLOW_FILE_MODS', false ); // Set true to prevent plugin/theme installs via admin
define( 'FORCE_SSL_ADMIN', true ); // Always use HTTPS for wp-admin and wp-login
define( 'WP_AUTO_UPDATE_CORE', true ); // Allow minor core security updates automatically
// ── Debug settings — NEVER enable on production ──────────────────────────────
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', false );
define( 'WP_DEBUG_DISPLAY', false );
define( 'SCRIPT_DEBUG', false );
@ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed
// ── Limit post revisions to reduce DB bloat ──────────────────────────────────
define( 'WP_POST_REVISIONS', 5 );
// ── Absolute path ─────────────────────────────────────────────────────────────
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';Block Direct Web Access via .htaccess
# Block direct HTTP access to wp-config.php and sensitive config files.
# Place inside your VirtualHost or root .htaccess.
<FilesMatch "^(wp-config\.php|\.htaccess|php\.ini|error_log|debug\.log)$">
Order Allow,Deny
Deny from all
Satisfy All
</FilesMatch>06. File & Directory Permissions
A default WordPress install on shared hosting often ends up with 777 directory permissions or world-writable files — sometimes by the installer, sometimes by FTP clients that mirror local permissions. World-writable PHP files allow any process on the server to modify your code, making malware injection trivial in shared-hosting environments.
| Path | Recommended Permission | Owner | Notes |
|---|---|---|---|
/ (web root) |
755 | www-data | No write needed for non-FTP deploys |
wp-config.php |
400 | www-data | Read-only by owner, nothing else |
.htaccess |
444 | www-data | Let WordPress rewrite it only during flush |
wp-content/uploads/ |
755 | www-data | Must be writable by web server for media uploads |
wp-content/ |
755 | www-data | Theme/plugin installs need group write in some stacks |
*.php (all) |
644 | www-data | Never 666 or 777 |
Bulk Permission Reset Script
#!/usr/bin/env bash
# Harden WordPress file and directory permissions.
# Run as root or with sudo. Replace WP_ROOT with your actual path.
WP_ROOT="/var/www/html"
WP_OWNER="www-data"
# Directories: 755 — traversable but not writable by others
find "${WP_ROOT}" -type d -exec chmod 755 {} \;
# PHP and config files: 644
find "${WP_ROOT}" -type f -name "*.php" -exec chmod 644 {} \;
find "${WP_ROOT}" -type f -name "*.js" -exec chmod 644 {} \;
find "${WP_ROOT}" -type f -name "*.css" -exec chmod 644 {} \;
# Uploads directory: allow web-server writes for media
find "${WP_ROOT}/wp-content/uploads" -type d -exec chmod 755 {} \;
find "${WP_ROOT}/wp-content/uploads" -type f -exec chmod 644 {} \;
# Sensitive files: owner read-only
chmod 400 "${WP_ROOT}/wp-config.php"
chmod 444 "${WP_ROOT}/.htaccess"
# Set ownership
chown -R "${WP_OWNER}:${WP_OWNER}" "${WP_ROOT}"
echo "Permissions hardened on ${WP_ROOT}"07. Default Database Prefix & SQL Injection Risk
Every WordPress installation uses wp_ as the default database table prefix. This means every SQL injection payload ever written for WordPress works against your database without modification. A blind SQLi payload targeting wp_users or wp_usermeta is a copy-paste exploit for the attacker.
Change the Prefix on New Installations
<?php
/**
* Set a unique, unpredictable table prefix in wp-config.php.
* Use 3–6 lowercase letters and/or digits followed by an underscore.
* Avoid words that hint at WordPress (wp_, wordpress_, blog_).
*
* For EXISTING installations, use a plugin such as "Change Table Prefix"
* or the WP-CLI command below — do NOT change this value manually after
* installation without also renaming all tables and updating user_meta keys.
*/
$table_prefix = 'zx9k_'; // Example — generate your own random stringChange Prefix on an Existing Site via WP-CLI
# Requires WP-CLI (https://wp-cli.org) installed and in PATH.
# Backup your database first — this operation modifies table names directly.
wp db export backup-before-prefix-change.sql --allow-root
# Change the prefix. WP-CLI renames all tables and updates usermeta/sitemeta keys.
wp search-replace 'wp_' 'zx9k_' --network --skip-columns=guid --allow-root
# Manually rename tables (WP-CLI does this, but verify with):
wp db query "SHOW TABLES LIKE 'wp_%';" --allow-root
# Should return empty — all tables should now use the new prefix.Parameterized Queries — The Real SQL Injection Defence
Changing the prefix reduces the portability of generic SQLi payloads, but it does not prevent SQL injection in custom plugin or theme code. Always use the WordPress $wpdb API with prepared statements:
<?php
/**
* Correct: Use $wpdb->prepare() for ALL dynamic queries.
*
* @param int $user_id Authenticated user ID from current_user_can() check.
* @param string $post_status Post status to filter by.
* @return array|object|null Query results.
*/
function oda_get_user_posts( int $user_id, string $post_status ): array {
global $wpdb;
// Whitelist the status value — do NOT pass arbitrary user input here.
$allowed_statuses = array( 'publish', 'draft', 'pending', 'private' );
if ( ! in_array( $post_status, $allowed_statuses, true ) ) {
return array();
}
// %d = integer placeholder, %s = string placeholder.
// prepare() adds its own quoting; never add extra quotes around placeholders.
$query = $wpdb->prepare(
"SELECT ID, post_title, post_date
FROM {$wpdb->posts}
WHERE post_author = %d
AND post_status = %s
ORDER BY post_date DESC
LIMIT 50",
$user_id,
$post_status
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query IS prepared above
return $wpdb->get_results( $query );
}
/**
* WRONG — never do this:
*
* $wpdb->query( "SELECT * FROM {$wpdb->posts} WHERE ID = " . $_GET['id'] );
* // ^^^^^^^^^^^
* // Direct injection point
*/08. Weak Authentication Defaults
WordPress ships with no brute-force protection on wp-login.php. There is no built-in account lockout, no CAPTCHA, no rate limiting, and no two-factor authentication. Combined with the user enumeration vectors above, this makes credential brute-forcing the most common WordPress attack vector by a wide margin.
Rename or Protect wp-login.php
# Restrict wp-login.php to specific IP ranges.
# Replace 203.0.113.10 with your own static IP or CIDR range.
<Files "wp-login.php">
Order Deny,Allow
Deny from all
Allow from 203.0.113.10
Allow from 198.51.100.0/24
</Files>Add HTTP Basic Auth as a Second Layer
# Double-layer authentication: HTTP Basic Auth before WordPress login form.
# Create the .htpasswd file with: htpasswd -c /etc/apache2/.htpasswd youruser
<Files "wp-login.php">
AuthType Basic
AuthName "Restricted Area"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user
</Files>Rate-Limit Login Attempts in PHP (Transient-Based)
<?php
/**
* Simple transient-based login rate limiter.
*
* Locks out an IP after 5 failed attempts within a 15-minute window.
* For production sites prefer a dedicated plugin (e.g. Wordfence, Limit
* Login Attempts Reloaded) which handles edge cases and IPv6 correctly.
*
* @param WP_Error $errors WP_Error object from the login form.
* @param string $user_login The attempted username.
* @return void
*/
function oda_login_rate_limit( WP_Error $errors, string $user_login ): void { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$transient_key = 'login_fail_' . md5( $ip );
$attempts = (int) \get_transient( $transient_key );
$max_attempts = 5;
$lockout_secs = 15 * MINUTE_IN_SECONDS;
if ( $attempts >= $max_attempts ) {
$errors->add(
'too_many_attempts',
__( 'Too many failed login attempts. Please try again in 15 minutes.', 'oda-security' )
);
// Short-circuit the rest of the login process.
\wp_die(
\esc_html( $errors->get_error_message( 'too_many_attempts' ) ),
\esc_html__( 'Login Blocked', 'oda-security' ),
array( 'response' => 429 )
);
}
}
\add_action( 'wp_login_failed', 'oda_increment_login_fail_count', 10, 1 );
\add_filter( 'authenticate', 'oda_login_rate_limit', 30, 2 );
/**
* Increment the failure counter after each failed authentication.
*
* @param string $username The username that failed.
* @return void
*/
function oda_increment_login_fail_count( string $username ): void { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$ip = \sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$transient_key = 'login_fail_' . md5( $ip );
$attempts = (int) \get_transient( $transient_key );
\set_transient( $transient_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
}Disable Login Hints (Username/Password Enumeration via Errors)
<?php
/**
* Replace WordPress login error messages with a generic message.
*
* By default WordPress tells attackers "Incorrect password for user admin"
* confirming that the username exists. This filter returns the same message
* regardless of whether the username or the password was wrong.
*
* @param string $error The original error message HTML.
* @return string
*/
function oda_generic_login_error( string $error ): string { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return '<strong>ERROR</strong>: Invalid username or password.';
}
\add_filter( 'login_errors', 'oda_generic_login_error' );09. PHP & WordPress Error Disclosure
With WP_DEBUG enabled — which happens any time a developer forgets to turn it off before launch, or when a hosting panel sets it by default — WordPress outputs full PHP stack traces, database query errors, and file paths directly into the HTML source of every page. This hands attackers your entire directory structure, plugin list, theme name, and database schema.
Safe Logging to File (Instead of Screen)
<?php
/**
* Production-safe debug configuration.
*
* Errors are logged to a file OUTSIDE the web root, never displayed.
* The log file path must not be web-accessible.
*/
// Never true in production.
define( 'WP_DEBUG', false );
// Log to a file instead of outputting to screen.
define( 'WP_DEBUG_LOG', '/var/log/wordpress/debug.log' );
// Never display errors to end users.
define( 'WP_DEBUG_DISPLAY', false );
// Suppress PHP notices and warnings from reaching output buffers.
@ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed
@ini_set( 'log_errors', '1' ); // phpcs:ignore WordPress.PHP.IniSet.log_errors_Disallowed
@ini_set( 'error_log', '/var/log/wordpress/php-error.log' ); // phpcs:ignore WordPress.PHP.IniSetProtect the Debug Log File
# If wp-content/debug.log exists on your server, block direct web access.
# WordPress creates this file by default when WP_DEBUG_LOG is true.
<Files "debug.log">
Order Allow,Deny
Deny from all
Satisfy All
</Files>10. Directory Listing & Sensitive File Exposure
If your web server has Options Indexes enabled (the Apache default on many shared hosts), any directory that lacks an index.php or index.html file will display a browsable directory listing. In wp-content/uploads/ this exposes every file your users have ever uploaded, including private documents, backup ZIPs, or exported CSVs that were uploaded through the admin area.
Disable Directory Listing
# Global: disable directory indexes for all directories.
# Add to the root .htaccess.
Options -Indexes
# Block direct PHP execution inside the uploads directory.
# Prevents malicious file uploads from being executed as PHP.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^wp-content/uploads/.*\.php$ - [F,L]
RewriteRule ^wp-content/uploads/.*\.php5$ - [F,L]
RewriteRule ^wp-content/uploads/.*\.phtml$ - [F,L]
</IfModule># Nginx: disable autoindex and block PHP execution in uploads.
autoindex off;
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}Add Protective index.php to All wp-content Subdirectories
<?php
/**
* Silence is golden.
*
* Place this file in:
* wp-content/index.php
* wp-content/plugins/index.php
* wp-content/themes/index.php
* wp-content/uploads/index.php
*
* WordPress ships with some of these already, but third-party theme and
* plugin installation processes often create subdirectories without them.
* A deployment script should ensure every new directory gets one.
*/
// Intentionally empty.11. Version Fingerprinting via Update Metadata
A default WordPress install broadcasts its exact version number in at least five different places. Automated scanners like WPScan, Nuclei templates, and Shodan crawlers use this to instantly match your site against CVE databases and identify unpatched vulnerabilities.
| Fingerprint Location | Example Value | Removable? |
|---|---|---|
<meta name="generator"> in <head> |
WordPress 6.5.2 |
Yes — filter |
/feed/ and /feed/rss2/ generator tag |
<generator>https://wordpress.org/?v=6.5.2</generator> |
Yes — filter |
/readme.html |
Full version on page title | Yes — delete file |
?ver= query string on scripts/styles |
wp-includes/js/jquery/jquery.min.js?ver=3.7.1 |
Partially — filter |
/wp-json/ root response |
"version":"6.5.2" in JSON body |
Partially |
Remove Generator Tags and Version Strings
<?php
/**
* Remove all default WordPress version fingerprinting.
*
* @package ODA_Security
*/
// Remove <meta name="generator" content="WordPress x.x.x" /> from <head>.
\remove_action( 'wp_head', 'wp_generator' );
// Remove version from RSS/Atom feeds.
\add_filter( 'the_generator', '__return_empty_string' );
/**
* Strip the ?ver= query string from enqueued scripts and styles.
*
* Note: This only removes version fingerprinting from the *output*.
* It does not affect how WordPress tracks asset cache-busting internally.
*
* @param string $src The script or style source URL.
* @return string URL without ?ver= parameter.
*/
function oda_remove_version_from_assets( string $src ): string {
if ( strpos( $src, 'ver=' ) !== false ) {
$src = \remove_query_arg( 'ver', $src );
}
return $src;
}
\add_filter( 'style_loader_src', 'oda_remove_version_from_assets', 9999 );
\add_filter( 'script_loader_src', 'oda_remove_version_from_assets', 9999 );
/**
* Delete readme.html and license.txt from the web root on activation.
*
* These files expose the WordPress version on their first line and serve
* no operational purpose on a live site.
*/
function oda_remove_fingerprint_files(): void {
$files = array(
ABSPATH . 'readme.html',
ABSPATH . 'license.txt',
ABSPATH . 'wp-admin/install.php', // After installation is complete.
);
foreach ( $files as $file ) {
if ( file_exists( $file ) ) {
\wp_delete_file( $file );
}
}
}
\register_activation_hook( __FILE__, 'oda_remove_fingerprint_files' );Removing version strings provides security through obscurity — it raises the attacker’s cost but does not fix underlying vulnerabilities. Always apply core, theme, and plugin updates immediately. Version hiding and patching are complementary, not alternatives.
12. Master Hardening Checklist
/?author=N enumeration via redirect hook/wp-json/wp/v2/users endpointwp-login.phpwp-config.php above web rootDISALLOW_FILE_EDIT true in wp-config.phpWP_DEBUG false, WP_DEBUG_DISPLAY false on productionwp_)readme.html and license.txtwp-content/uploads/Options -Indexes)X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, Referrer-Policy.git/, .env, and other dot-files13. Conclusion
Every vulnerability documented in this article is present in a stock WordPress installation with zero plugins, zero customisation, and zero negligence on the part of the site owner. They are defaults — choices made to ease onboarding at the direct expense of security posture.
The good news is that every single one has a well-understood fix. None of the hardening steps above require a paid plugin or a specialist retainer. They require roughly two hours of careful configuration against your wp-config.php, your web server config, and a single security-focused functions.php file.
The bad news is that most WordPress sites are never hardened at all. If your site is publicly accessible and has not been through this checklist, assume that automated scanners have already catalogued every default weakness and added your domain to a target list.
Check our article about how to create a security scanner yourself: WordPress Security Scanner and use it to detect any residual vulnerabilities in your installed plugins and themes.
Comments (0)
Join the conversation. to leave a comment.