Hide WordPress version and harden HTTP headers

Hide WordPress version and harden HTTP headers

Last verified: October 15, 2026
19 min read
Guide
Security auditor

In the world of cybersecurity, there is a concept known as reconnaissance.

Before a hacker (or an automated bot) attacks your site, they scan it. They look for low-hanging fruit. If your website announces “WordPress 5.8.1” in its source code, the scanner knows exactly which published vulnerabilities applied to 5.8.1 and which patches you may have missed.

This guide goes beyond simple “Hide Version” plugins. It covers security through obscurity (and why it is only the first layer of defense), the headers WordPress itself sends, and then real server-level hardening with HTTP headers, login protection and file permissions.


#Is security through obscurity enough for WordPress

#What is security through obscurity

Security through obscurity is the practice of hiding information about your system to make it harder for attackers to find vulnerabilities. It is controversial in security circles, but combined with proper security measures it adds an extra layer of defense.

The debate:

  • Critics say: “A system should be secure even if attackers know everything about it”
  • Practitioners say: “Why make it easier for attackers by broadcasting your vulnerabilities?”

In practice: security through obscurity is not sufficient on its own, but it is a useful first layer. It will not stop a determined attacker, but it takes your site out of the simplest mass scans that match a version string against a CVE list.

#Why hiding WordPress version matters

WordPress core vulnerabilities are publicly documented. When a security release ships, the release notes state which versions are affected. By displaying your version number, you tell attackers:

  1. Which known vulnerabilities might exist on your site
  2. Whether you have applied the latest security patches
  3. Which specific exploit code might work against you

Common version leakage points:

  • HTML meta generator tag
  • RSS/Atom feed generator tags
  • Script and style query strings (?ver=6.7.1)
  • readme.html file in the root directory

#How to hide WordPress version number

#The generator meta tag

By default, WordPress injects a meta tag into the <head> of your HTML:

<meta name="generator" content="WordPress 6.7.1" />

This is useless for SEO and dangerous for security. Core prints it through the wp_generator callback on wp_head, and the same the_generator callback runs on every feed head (rss2_head, commentsrss2_head, rss_head, rdf_header, atom_head, comments_atom_head, opml_head, app_head), as listed in wp-includes/default-filters.php.

#PHP snippet to remove the WordPress version

Do not install a heavyweight plugin just for this. Put the code in a must-use plugin (wp-content/mu-plugins/hardening.php), so it survives a theme switch:

<?php
/**
 * Plugin Name: WPPoland hardening
 * Description: Removes the core version from head, feeds and core assets.
 */

// Generator tag in <head>
remove_action('wp_head', 'wp_generator');

// Generator tag in every feed type: one filter covers all of them
add_filter('the_generator', '__return_empty_string');

// Really Simple Discovery link (only needed by XML-RPC clients)
remove_action('wp_head', 'rsd_link');

/**
 * Remove ?ver= only when it equals the core version.
 * Plugins and themes keep their own version strings for cache busting.
 */
function wppoland_strip_core_version(string $src): string {
    global $wp_version;

    $query = parse_url($src, PHP_URL_QUERY);
    if (!$query) {
        return $src;
    }

    parse_str($query, $args);
    if (isset($args['ver']) && $args['ver'] === $wp_version) {
        return remove_query_arg('ver', $src);
    }

    return $src;
}
add_filter('style_loader_src', 'wppoland_strip_core_version', 9999);
add_filter('script_loader_src', 'wppoland_strip_core_version', 9999);

Many copies of this snippet also remove wlwmanifest_link. That function has been deprecated since WordPress 6.3.0, because the Windows Live Writer manifest is no longer included in core, so the line does nothing on a current install.

Why the selective filter matters: Many snippets strip every ver parameter from every asset. That breaks cache busting: after a plugin update the URL stays the same and browsers and CDNs keep serving the old file. Comparing the value with $wp_version hides the core version and leaves plugin and theme versions alone.

Important note: this is security through obscurity. It is the first layer, not the only layer. A determined attacker can still fingerprint WordPress through other methods (REST API endpoints, file structure, and the headers described in the next section).

#Block readme.html and license.txt

readme.html and license.txt sit in the web root of every install. Block them in the web server, so the request never reaches PHP.

Apache 2.4 (.htaccess):

<FilesMatch "^(readme\.html|license\.txt)$">
  Require all denied
</FilesMatch>

Nginx:

location ~* ^/(readme\.html|license\.txt)$ {
    deny all;
}

The old Order deny,allow / Deny from all syntax belongs to Apache 2.2. On Apache 2.4 it only works through mod_access_compat, so use Require all denied.


#Which HTTP headers reveal WordPress besides the generator tag

The generator tag is not the only fingerprint. Core and PHP add response headers that tell a scanner which software runs the site, even when no version number is printed anywhere.

HeaderSent byWhen
Link: <https://example.com/wp-json/>; rel="https://api.w.org/"rest_output_link_header(), since WordPress 4.4.0every front-end request, on template_redirect (priority 11)
Link: <https://example.com/?p=123>; rel=shortlinkwp_shortlink_header()single posts and pages, on template_redirect (priority 11)
X-Pingback: https://example.com/xmlrpc.phpWP::send_headers()single posts that accept pings (pings_open()), conditional since 4.4.0
X-Powered-By: PHP/8.3.xPHP itselfwhile expose_php is on, which is the default

Sources: the rest_output_link_header reference, the WP::send_headers reference and the PHP core ini documentation.

The WordPress headers come off with the same hooks that add them. The X-Pingback header goes through the wp_headers filter, which core describes as filtering the HTTP headers before they are sent to the browser:

// REST API discovery: header and <link> tag
remove_action('template_redirect', 'rest_output_link_header', 11);
remove_action('wp_head', 'rest_output_link_wp_head', 10);

// Shortlink: header and <link> tag
remove_action('template_redirect', 'wp_shortlink_header', 11);
remove_action('wp_head', 'wp_shortlink_wp_head', 10);

// X-Pingback on single posts
add_filter('wp_headers', function (array $headers): array {
    unset($headers['X-Pingback']);
    return $headers;
});

Removing the discovery link does not switch off the REST API: /wp-json/ still answers, and the block editor depends on it. The point is to stop advertising the CMS on every HTML response, not to break the API.

X-Powered-By cannot be changed from WordPress. The PHP manual lists expose_php as a php.ini-only setting, so ini_set() has no effect. Set it in the PHP-FPM pool or the main php.ini:

expose_php = Off

The OWASP HTTP Headers Cheat Sheet recommends removing every X-Powered-By header and either removing Server or giving it a non-informative value. It also notes that attackers can still fingerprint the stack by other means, which is the honest limit of this whole section.


#HTTP security headers for WordPress

Hiding the version is like taking the house number off your door. It helps, but it does not lock the door. HTTP security headers are the deadbolts.

Browsers enforce these policies only when the server sends them. Without them you rely on browser defaults against cross-site scripting (XSS), clickjacking and data leaks.

Set them in the web server, not in PHP. A full-page cache (Nginx FastCGI cache, Varnish, a CDN) serves stored responses without running PHP, so a header() call in a theme does not reach anonymous visitors.

#1. X-Frame-Options (clickjacking protection)

This header tells the browser: “Do not allow other sites to put my page inside an <iframe>.” This prevents attackers from loading your login or checkout page in an invisible frame and capturing clicks.

Implementation (.htaccess):

<IfModule mod_headers.c>
  Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>

Nginx:

add_header X-Frame-Options "SAMEORIGIN" always;

Options:

  • DENY: the page cannot be displayed in a frame
  • SAMEORIGIN: the page can only be displayed in a frame on the same origin
  • ALLOW-FROM uri: obsolete. MDN states that modern browsers that see this directive ignore the whole header; use CSP frame-ancestors instead

MDN also warns that X-Frame-Options set through <meta http-equiv> has no effect. It only works as an HTTP header.

#2. X-Content-Type-Options

Prevents MIME sniffing. If an attacker uploads a .jpg file that actually contains JavaScript, this header tells the browser: “The server said it is an image, so treat it as an image, do not execute it.”

Implementation:

<IfModule mod_headers.c>
  Header always set X-Content-Type-Options "nosniff"
</IfModule>

Nginx:

add_header X-Content-Type-Options "nosniff" always;

#3. Strict-Transport-Security (HSTS)

This is critical for TLS. It forces the browser to always use HTTPS, even if the user types http://. It prevents downgrade and man-in-the-middle attacks on public Wi-Fi, after the first secure visit.

Implementation (only with a valid certificate on every subdomain):

<IfModule mod_headers.c>
  Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
</IfModule>

Nginx:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

What the parameters mean:

  • max-age=31536000: the browser remembers the policy for 1 year
  • includeSubDomains: apply to all subdomains (e.g., blog.yoursite.com)
  • preload: a request to be included in the browser preload list, with the consequences described below

Warning: once you set this header, browsers refuse to connect via HTTP for the specified duration. Make sure HTTPS works on every host before implementing HSTS.

#4. Content-Security-Policy (CSP)

This is the most powerful header. It tells the browser: “Only execute scripts from these trusted sources.”

Basic implementation:

<IfModule mod_headers.c>
  Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'self';"
</IfModule>

Breaking it down:

  • default-src 'self': by default, only load resources from your own domain
  • script-src 'self' 'unsafe-inline': allow scripts from your domain and inline scripts (many themes and plugins still need them)
  • img-src 'self' data: https:: allow images from your domain, data URIs and any HTTPS source
  • style-src 'self' 'unsafe-inline': allow styles from your domain and inline styles
  • frame-ancestors 'self': the CSP replacement for X-Frame-Options

Warning: CSP can break your site if misconfigured. Test in report-only mode first:

Header always set Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report.php"

#5. Referrer-Policy

Controls how much information is sent when a user clicks a link to another site.

Implementation:

<IfModule mod_headers.c>
  Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

Nginx:

add_header Referrer-Policy "strict-origin-when-cross-origin" always;

This sends the full URL to same-origin requests, but only the origin (domain) to cross-origin requests.

Options:

  • no-referrer: never send referrer information
  • no-referrer-when-downgrade: do not send the referrer when going from HTTPS to HTTP
  • origin: only send the origin
  • strict-origin: only send the origin, never when downgrading
  • same-origin: full URL for same origin, no referrer for cross-origin
  • strict-origin-when-cross-origin: full URL for same origin, origin only for cross-origin

#6. Permissions-Policy (formerly Feature-Policy)

Disables browser features you do not use (camera, microphone, geolocation).

Implementation:

<IfModule mod_headers.c>
  Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>

Nginx:

add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

Common directives:

  • camera=(): disable camera access
  • microphone=(): disable microphone access
  • geolocation=(): disable geolocation
  • payment=(): disable the Payment Request API
  • usb=(): disable WebUSB access

#7. Should X-XSS-Protection still be set in 2026

Older guides recommended X-XSS-Protection: 1; mode=block. That advice is out of date. The MDN page for X-XSS-Protection marks the header as deprecated and non-standard, recommends a Content-Security-Policy instead, and warns that in some cases the filter can create XSS vulnerabilities in otherwise safe websites. MDN adds that mode=block may be open to side-channel attacks when the page can be framed.

OWASP gives the same answer: do not set the header, or set it explicitly to 0. If an old server config or a plugin already sends 1; mode=block, overwrite it:

Header always set X-XSS-Protection "0"
add_header X-XSS-Protection "0" always;

The protection you actually want comes from a CSP that does not allow 'unsafe-inline' scripts, which is a longer project on most WordPress sites.


#Does WordPress send X-Frame-Options by itself

Yes, on two screens. Core hooks send_frame_options_header() to login_init and, in wp-admin/includes/admin-filters.php, to admin_init. The function, available since WordPress 3.1.3, sends two headers when no headers have been sent yet:

X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self';

Source: the send_frame_options_header reference.

So wp-login.php and the dashboard are protected against framing even without server config. The public front end is not: themes, checkout pages and contact forms get no such header unless you add it.

This has a practical consequence for Apache. The mod_headers documentation says set replaces any previous header with the same name, while append adds the new value to the existing header, separated by a comma. With Header append, the login page carries the value from WordPress plus your value. With Header always set, it carries exactly one. The same documentation explains that headers produced by PHP behind mod_proxy_fcgi live in the always table, which is why the examples here use Header always set throughout.


#Why Nginx security headers disappear in location blocks

A common surprise after a “working” Nginx config: curl -I on the home page shows every security header, but /wp-login.php or a static file shows none of them. The ngx_http_headers_module documentation explains why: add_header directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.

One add_header Cache-Control ... inside location ~* \.(css|js)$ is enough to drop every security header from that location. Two ways out:

  1. Keep the security headers in a snippet and include it in the server block and in every location that defines its own add_header:
# /etc/nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location ~* \.(css|js|woff2)$ {
    include snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable";
}
  1. On Nginx 1.29.3 or newer, add_header_inherit merge; appends the values from the previous level to the current one instead of replacing them.

The always flag matters too. Without it, Nginx adds the header only to responses with status 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308. A 403 on /xmlrpc.php or a 404 page goes out bare.


#What the HSTS preload list requires

The preload token in the header above is not a setting that takes effect by itself. It is a request to be hardcoded into browsers, and hstspreload.org lists the conditions for submission:

  • serve a valid certificate,
  • redirect from HTTP to HTTPS on the same host, if you listen on port 80,
  • serve all subdomains over HTTPS, including www if a DNS record exists for it,
  • send the HSTS header on the base domain over HTTPS with max-age of at least 31536000 seconds, includeSubDomains and preload,
  • if the HTTPS site redirects further, the redirect itself must carry the HSTS header.

The same page warns that preloading applies to all subdomains, including internal ones that are not publicly accessible, that new entries can take several months to reach stable Chrome, and that removal “tends to be slow and painful”. A staging host on a subdomain without a certificate stops loading in every browser that has the list.

OWASP’s recommended value is max-age=63072000; includeSubDomains; preload, two years. The order that avoids lockouts: start with a short max-age without preload, check every subdomain, raise the value, and submit only when the whole domain is HTTPS-only for good. If you are not ready for that commitment, leave preload out of the header.


#How to disable XML-RPC in WordPress

xmlrpc.php is a legacy API file. The REST API has replaced it for most integrations. It remains a frequent brute force target because the system.multicall method lets one HTTP request carry many login attempts, which per-request login limiters do not count separately.

#Block xmlrpc.php in .htaccess, PHP or Nginx

Method 1: .htaccess (Apache 2.4)

<Files xmlrpc.php>
  Require all denied
</Files>

Method 2: Nginx

location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

Method 3: functions.php (partial)

add_filter('xmlrpc_enabled', '__return_false');

Do not treat method 3 as a full block. The xmlrpc_enabled reference says that, contrary to its name, the filter only controls XML-RPC methods that require authentication. Pingbacks and other endpoints that need no login stay open. Only the web server rule stops the request before PHP runs.

Exception: if you use Jetpack or the WordPress mobile app with a self-hosted site, you need XML-RPC. In that case, use a plugin such as Disable XML-RPC Pingback to remove only the pingback methods.


#How to secure the WordPress login page

The /wp-admin/ and /wp-login.php endpoints are under constant attack. Here is how to protect them.

#1. Limit login attempts

By default, WordPress allows unlimited login attempts.

Plugin: Limit Login Attempts Reloaded, or fail2ban on the server reading failed logins from the access log. A minimal mu-plugin version has to hook into authenticate, otherwise the counter never blocks anything:

add_filter('authenticate', function ($user) {
    $key = 'wppoland_login_fail_' . md5($_SERVER['REMOTE_ADDR'] ?? '');
    if ((int) get_transient($key) >= 5) {
        return new WP_Error('too_many_attempts', 'Too many failed logins. Try again in 15 minutes.');
    }
    return $user;
}, 30);

add_action('wp_login_failed', function () {
    $key = 'wppoland_login_fail_' . md5($_SERVER['REMOTE_ADDR'] ?? '');
    set_transient($key, (int) get_transient($key) + 1, 15 * MINUTE_IN_SECONDS);
});

Behind a reverse proxy or CDN, REMOTE_ADDR is the proxy address, so this code would lock out everyone at once. Configure the real client IP in the web server first.

#2. Two-factor authentication (2FA)

Even if an attacker gets your password, they cannot log in without the second factor.

Plugins:

#3. Change the login URL

By default, everyone knows your login is at /wp-login.php.

Plugin: WPS Hide Login

This changes the URL to something like /my-secret-login/. Bots scanning for /wp-login.php get a 404. This is obscurity again: it cuts log noise but does not replace 2FA.

#4. Disable user enumeration

By default, you can discover usernames by visiting /?author=1 or /wp-json/wp/v2/users.

Fix:

// Redirect author archives requested by ID
add_action('template_redirect', function (): void {
    if (is_author() && !is_admin()) {
        wp_safe_redirect(home_url(), 301);
        exit;
    }
});

// Hide the users endpoints from visitors without list_users
add_filter('rest_endpoints', function (array $endpoints): array {
    if (current_user_can('list_users')) {
        return $endpoints;
    }
    unset($endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    return $endpoints;
});

The list_users check matters: without it, editors in the block editor lose the author dropdown, which reads the same endpoint.


#How to secure the WordPress database

#1. Change the table prefix

By default, WordPress uses wp_ as the table prefix. A custom prefix does not prevent SQL injection; it only breaks automated scripts that hardcode wp_ table names.

During installation: set a custom prefix like xyz_ or prod_.

After installation: use a plugin like Change Table Prefix or rename tables and the prefixed option and usermeta keys manually (take a backup first).

#2. Disable file editing

WordPress allows admins to edit theme/plugin files from the dashboard. If an attacker gets admin access, they can inject malware.

Disable it in wp-config.php:

define('DISALLOW_FILE_EDIT', true);

#3. Secure wp-config.php

This file contains your database credentials. Protect it.

Move it one directory up: WordPress also looks for wp-config.php one level above the web root.

Or set strict permissions:

chmod 400 wp-config.php

#WordPress file permissions 755 and 644

Often overlooked. If your file permissions are wrong, an attacker who compromises one plugin can rewrite your entire core.

#Which permissions to use for files and folders

  • Directories: 755 (read/execute for everyone, write only for owner)
  • Files: 644 (read for everyone, write only for owner)
  • wp-config.php: 400 or 440 (read only for owner/server)

#How to set permissions with chmod

find /path/to/wordpress -type d -exec chmod 755 {} \;
find /path/to/wordpress -type f -exec chmod 644 {} \;
chmod 400 wp-config.php

#How to verify the hardening with curl

Check the real responses, including the ones a cache serves, after every change:

# Security headers on the home page
curl -sI https://example.com/ | grep -iE "strict-transport|x-frame|x-content|referrer|permissions|content-security"

# Headers on the login page: exactly one X-Frame-Options line expected
curl -sI https://example.com/wp-login.php | grep -i x-frame

# Fingerprints that should be gone
curl -sI https://example.com/ | grep -iE "x-powered-by|x-pingback|api.w.org"
curl -s https://example.com/ | grep -i generator

# Blocked files should return 403
curl -sI https://example.com/xmlrpc.php | head -1
curl -sI https://example.com/readme.html | head -1

Run the header check against a 404 URL too. On Nginx without always, that is where the headers go missing.


#WordPress security monitoring without plugins

We do not recommend installing security plugins for monitoring. Prefer server-level monitoring (log analysis, fail2ban, hosting WAF), regular backups and hardening (strong passwords, updates, limited login attempts). Security is built through configuration and server measures.


#WordPress security checklist

#Immediate actions (today)

  • Remove WordPress version from HTML/feeds
  • Remove the core version string from CSS/JS
  • Set X-Frame-Options header
  • Set X-Content-Type-Options header
  • Set Strict-Transport-Security (HSTS) if using HTTPS
  • Block XML-RPC in the web server (if not needed)
  • Limit login attempts
  • Change default “admin” username

#Short-term actions (this week)

  • Implement Content-Security-Policy (report-only first)
  • Set Referrer-Policy
  • Remove X-XSS-Protection or set it to 0
  • Enable two-factor authentication
  • Disable user enumeration
  • Set correct file permissions (755/644)

#Long-term actions (this month)

  • Change database table prefix (if possible)
  • Disable file editing in dashboard
  • Secure wp-config.php (400 permissions)
  • Set expose_php = Off
  • Set up server-level monitoring and automated backups
  • Decide on HSTS preload only after every subdomain is HTTPS

#Summary of layered WordPress security

Learn more about WordPress security services at WPPoland. Security is not a product; it is a process.

  1. Hide info (version strings, fingerprint headers): security through obscurity
  2. Harden the connection (HSTS, headers, CSP): browser-level protection
  3. Close doors (block XML-RPC, limit logins): reduce the attack surface
  4. Lock files (permissions, disable editing): contain breaches
  5. Monitor (server logs, alerts): detect and respond

Security is layered. No single measure is perfect. Start with the basics (updates, strong passwords), add obscurity (hide the version), then implement hardening (headers, permissions), and finally monitoring.

The goal is not to be impossible to compromise. The goal is to be a harder target than the next site, so that automated attacks move on.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

Is hiding the WordPress version enough for security#
No. Hiding the version is security through obscurity, a first layer of defense and not sufficient on its own. You must combine it with proper hardening: security headers, 2FA, regular updates, and strong passwords.
Which HTTP security headers matter most for WordPress#
Critical headers: X-Frame-Options (prevents clickjacking), Content-Security-Policy (prevents XSS), Strict-Transport-Security/HSTS (forces HTTPS), X-Content-Type-Options (prevents MIME sniffing), and Referrer-Policy.
Should XML-RPC be disabled in WordPress#
Yes, unless you use Jetpack, mobile apps, or external publishing tools. XML-RPC is a common brute force vector. Block it in the web server; the xmlrpc_enabled filter only switches off authenticated methods.
Do security headers affect website performance#
Not measurably. They are a few hundred bytes of response metadata that browsers use to enforce security policies. A too strict CSP can block resources, which is a functional problem, not a speed one.
Can hiding the WordPress version break the site#
Removing the generator tag is safe. Stripping every ?ver= parameter breaks cache busting for theme and plugin updates, so strip only the value that equals the core version.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles

PHP2Shell: is my WordPress site at risk and what to do

PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 21 October 2026, fix two critical vulnerabilities in PHP itself (CVE-2026-109618, CVE-2026-109619). How to check the PHP version in WordPress, what to do when your host has not updated the server yet, and which passwords to change after the update.