This post covers how to hide the WordPress version and why that helps the security of your site.

We’re about to launch a pro next-generation security plugin that hides your WordPress version. Get notified when it launches.

If you have a WordPress site you know that security is important. WordPress is really popular and this makes it a popular target for hackers. A part of securing your site is preventing attackers from learning anything they can use to help break in. To secure WordPress, an important aspect of preventing this information gathering is stopping people learning what WordPress version you’re using.

Each version of a particular piece of software has known vulnerabilities: bugs in the code that people could use to hack your site. If somebody learns your WordPress version, they then know what bugs (if any) are present that your site is vulnerable to, and this makes an attacker’s job easier. As an example, this is a list of known bugs in WordPress core — notice there’s several hundred, and that’s just in core WordPress alone, not including plugins and themes. And that’s not the only list of vulnerabilities.

Of course, the best approach is to always keep WordPress up to date, as this ensures you’re running the most secure version with the latest bugfixes. But perhaps a plugin you’re using doesn’t have support for the latest WordPress version yet, and you can’t upgrade without breaking another part of your site. Then you definitely want to hide the fact you’re running an out-of-date version of WordPress.

But even if you’re always running the latest WordPress version, the logic of denying information to attackers still applies. The goal is defence in depth, which means that you make all the different layers and aspects of your site secure, not just one. And hiding the WordPress version is a good part of a defence-in-depth strategy for your site.

Steps to Hide the WordPress Version

1. Hide the Generator Meta Tag in the HTML Source and the Blog Feeds

This is a comprehensive list of steps you need to take to hide your WordPress version. You’ll need to edit some code, so ensure you back up the changed files before making these edits. We’re editing functions.php in the current theme and these edits will be lost if the theme updates. It’s best to perform these edits in a custom theme that doesn’t have frequent updates, or use our plugin.

By default, your WordPress site displays a lot of information in the HTML source. (To see that, browse to your site, right click, and choose “View Source” or “View Page Source.”) One of the places the WordPress version is displayed is right there in the HTML, within the <head> element. WordPress adds a <meta> tag that looks like this:

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

As you can see, we’re displaying to everyone that we’re using WordPress version 5.7. It’s not too difficult to remove that <meta> tag, but it requires a little code.

That’s just one instance where the WordPress version is displayed. Your WordPress site also has RSS feeds, so people can subscribe to your blog’s content and be updated when you write a post.

<link rel="alternate" type="application/rss+xml" title="Ruminative WP &raquo; Feed" href="https://ruminativewp.com/feed/">
<link rel="alternate" type="application/rss+xml" title="Ruminative WP &raquo; Comments Feed" href="https://ruminativewp.com/comments/feed/">

The above code displays the HTML links for our site’s feeds – a content feed, and a comments feed. If you view the HTML source of your own site, you’ll see something similar. You can click through those links and see something like this:

.....
<generator>https://wordpress.org/?v=5.7</generator>

Clearly, we need to remove the generator tags from both the main HTML and the feeds’ XML. Here’s some code that removes the version from both HTML meta generator and XML. You can use the Theme Editor to add this code to your theme’s functions.php — but be aware that updating your theme will cause these edits to be lost. Use our plugin or add this code to a custom theme that doesn’t update often. To add the following code, use the WordPress admin and go to Appearance > Theme Editor > select functions.php and add this code at the end of the file.


add_filter( 'get_the_generator_html', 'example_filter_the_generator', 1000000, 2 );

if ( ! function_exists( 'example_filter_the_generator' ) ):
function example_filter_the_generator( $generator_type, $type ) {
    return '';
}
endif;

Then click “Update File” and reload your site to see the changes. The <meta name="generator" and feed generator tags should be gone. Note that any other generator tags, e.g. from WooCommerce, will be removed too.

Note also: in recent WordPress versions, the above code is enough to remove HTML and feed XML generator tags, but in older WP versions it might not be. In that case, our plugin has a more backwards-compatible solution.

2. Remove the WordPress Version from Static Asset URLs

Another thing that’s visible when you look at your HTML source is WordPress loading lots of Javascript and CSS files. Here’s an example, showing that the WordPress version (in this example, 5.7) is included in the URL:

<link rel="stylesheet" id="storefront-style-css" href="https://ruminativewp.com/wp-content/themes/storefront/style.css?ver=5.7" media="all">

This seems counterintuitive — because it is. WordPress deliberately includes the version in the URLs of static assets so that whenever WordPress is upgraded, a new version of those Javascript and CSS files will be loaded. The change in the version number in the URL causes visitor’s browsers not to use a cached copy of those files, if it has one. That works effectively for WordPress core, but it makes things annoying if you don’t want the version number there, because just removing the version parts breaks the functionality WordPress uses to load new assets when its upgraded.

Here’s code to just remove the version from the URL. WARNING. Using this below code will result in your site potentially breaking whenever WordPress updates, unless you also update the $fake_version variable whenever WordPress updates. (Don’t replace the value of $fake_version with a dynamic value that changes each request, like time() – use a constant value like “the next word in the dictionary” or a number.) Clearly replacing that value all the time is annoying. This aspect of WP functionality is one reason using a plugin to handle all this is a good idea.

$fake_version = 1;

if ( ! function_exists( 'example_replace_wp_vers_in_url' ) ):
function example_replace_wp_vers_in_url( $url ) {
    global $fake_version;
    
    $wp_vers_regex = str_replace( '.', '[.]', get_bloginfo('version') );
    $replace_version_with = $fake_version;                                                                            
    $tmp = preg_replace( '#[?]ver=' . $wp_vers_regex . '#', "?ver=$replace_version_with", $url );
    $tmp = preg_replace( '#[?]ver=' . $wp_vers_regex . '&#', "?ver=$replace_version_with&", $tmp );
    $tmp = preg_replace( '#&amp;ver=' . $wp_vers_regex . '#', "&amp;ver=$replace_version_with", $tmp );
    $tmp = preg_replace( '#&ver=' . $wp_vers_regex . '#', "&ver=$replace_version_with", $tmp );
    return $tmp;
}
endif;

add_filter( 'style_loader_tag', 'example_filter_style_loader_tags', 1000000, 4 );
if ( ! function_exists( 'example_filter_style_loader_tags' ) ):
function example_filter_style_loader_tags( $html, $handle, $href, $media ) {
    return example_replace_wp_vers_in_url( $html );
}
endif;

add_filter( 'script_loader_tag', 'example_filter_script_loader_tags', 1000000, 3 );
if ( ! function_exists( 'example_filter_script_loader_tags' ) ):
function example_filter_script_loader_tags( $tag, $handle, $src ) {
    return example_replace_wp_vers_in_url( $tag );
}
endif;

WARNING. The above code could break your site.

And even that’s not quite enough to handle all the static assets. WordPress includes emojis, which we all know are the devil, and — as you might expect — they require special handling to remove the version number from their URLs. Add the below code if you’re already using the code above with $fake_version etc.

add_filter( 'script_loader_src', 'example_filter_emoji_src', 100000000, 2 );
if ( ! function_exists( 'example_filter_emoji_src' ) ):
function example_filter_emoji_src( $src, $handle ) {
    global $fake_version;

    $replace_version_with = $fake_version;
    if ( 'concatemoji' == $handle ) {
        return includes_url( "js/wp-emoji-release.min.js?$replace_version_with" );
    }
    else {
        return $src;
    }
}
endif;

3. Block access to /wp-admin/install.php and /wp-admin/upgrade.php

After WordPress is installed, these files should not really be accessible to the public. They output static asset URLs like /wp-admin/includes/css/something.css?5.7 that include the WordPress version, even when using code like the above to hide it. This is because these two files use a completely different mechanism to include the WordPress version (the above files don’t use the same hooks). Since the files shouldn’t really be accessible anyway, instead of jumping through hoops to remove the version from their static asset URLs, it’s best to just block access to these files entirely, as long as you have automatic updates turned on.

If you’re using Apache as your web server, here’s code to add to your .htaccess file to block access to those pages. Ensure you place the new code outside the # BEGIN WordPress / # END WordPress lines, otherwise WordPress will overwrite your additions.

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^wp-admin/install.php$ [NC,R=404]
RewriteRule ^wp-admin/upgrade.php$ [NC,R=404]
</IfModule>

If you’re using nginx as your web server, add these lines within the relevant server {} block:

        location = /wp-admin/install.php {
                return 404;
        }
        location = /wp-admin/upgrade.php {
                return 404;
        }

WARNING. The above code should only be added if you are using automatic updates for WordPress core, otherwise it will block you from updating your own site.

4. Replace the Etag header in /wp-admin/load-styles.php and /wp-admin/load-scripts.php

This is definitely the most annoying part. WordPress uses the URLs above to load Javascript and CSS in the WordPress admin. However, both of these URLs insert the WordPress version into the response they send, using an ETag HTTP header. If your web server supports it, you could filter this header and change the value – but keep in mind that since ETag is a caching header, you’re potentially breaking some of WordPress’s caching functionality unless you change the replacement value every time WP upgrades. What’s required is to have custom scripts that do the same thing as load-styles.php and load-scripts.php but return a different ETag header, for example:

(... do the things these scripts normally do...)
header( "Etag: $fake_wp_version" );
...
echo $out;
exit;   

Then, you need to rewrite any requests to these scripts to their replacement files, e.g. using .htaccess

RewriteRule .*wp-admin/load-styles.php(.*) /example-modified-load-styles.php$1 [NC,L]

… or using nginx:

rewrite (?i)^.*wp-admin/load-styles.php(.*)$ /example-modified-load-styles.php$1 last;

(and the same again but with load-scripts.php of course)

Here’s example customised wp-admin/load-scripts.php and wp-admin/load-styles.php. Actually using these scripts is not easy, because WordPress overwrites core files when it updates. You need to place these in a custom location (e.g. in wp-content/) and add rewrite rules like above to ensure WordPress uses the customized versions.

load-scripts.php:

<?php    
    $fake_wp_version = somehow_generate_a_fake_version();

    if ( array_key_exists( 'SERVER_PROTOCOL', $_SERVER ) ) {
        $protocol = $_SERVER['SERVER_PROTOCOL'];
        if ( ! in_array( $protocol, array( 'HTTP/1.1', 'HTTP/2', 'HTTP/2.0' ), true ) ) {
            $protocol = 'HTTP/1.0';
        }    
    } else {
        $protocol = 'HTTP/1.0';
    }    

    if ( array_key_exists( 'load', $_GET ) ) {
        $load = $_GET['load'];
    } else {
        header( "$protocol 400 Bad Request" );
        exit;
    }    
    $load = array_unique( explode( ',', $load ) ); 

    if ( empty( $load ) ) {
        header( "$protocol 400 Bad Request" );
        exit;
    }    

    $expires_offset = 31536000; // 1 year.
    $out            = '';

    $wp_scripts = new WP_Scripts();
    wp_default_scripts( $wp_scripts );
    wp_default_packages_vendor( $wp_scripts );
    wp_default_packages_scripts( $wp_scripts );

    if ( isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) && stripslashes( $_SERVER['HTTP_IF_NONE_MATCH'] ) === $fake_wp_version ) {
        header( "$protocol 304 Not Modified" );
        exit;
    }    

    foreach ( $load as $handle ) {
        if ( ! array_key_exists( $handle, $wp_scripts->registered ) ) {
            continue;
        }    

        $path = ABSPATH . $wp_scripts->registered[ $handle ]->src;
        $out .= file_get_contents( $path ) . "\n";
    }    

    header( "Etag: $fake_wp_version" );
    header( 'Content-Type: application/javascript; charset=UTF-8' );      
    header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + $expires_offset ) . ' GMT' );
    header( "Cache-Control: public, max-age=$expires_offset" );

    echo $out;          // This is returned to browser with a set MIME type
    exit;   

load-styles.php:

<?php    
   $fake_wp_version = somehow_generate_a_fake_version();

    if ( array_key_exists( 'SERVER_PROTOCOL', $_SERVER ) ) {
        $protocol = $_SERVER['SERVER_PROTOCOL'];
        if ( ! in_array( $protocol, array( 'HTTP/1.1', 'HTTP/2', 'HTTP/2.0' ), true ) ) { 
            $protocol = 'HTTP/1.0';
        }
    } else {
        $protocol = 'HTTP/1.0';
    }
    
    if ( array_key_exists( 'load', $_GET ) ) {
        $load = $_GET['load'];
    } else {
        header( "$protocol 400 Bad Request" );
        exit;
    }
    $load = array_unique( explode( ',', $load ) );

    if ( empty( $load ) ) {
        header( "$protocol 400 Bad Request" );
        exit;
    }

    $rtl            = ( isset( $_GET['dir'] ) && 'rtl' === $_GET['dir'] );
    $expires_offset = 31536000; // 1 year.
    $out            = '';

    $wp_styles = new WP_Styles();
    wp_default_styles( $wp_styles );

    if ( isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) && stripslashes( $_SERVER['HTTP_IF_NONE_MATCH'] ) === $fake_wp_version ) {
        header( "$protocol 304 Not Modified" );
        exit;
    }

    foreach ( $load as $handle ) {
        if ( ! array_key_exists( $handle, $wp_styles->registered ) ) {
            continue;
        }

        $style = $wp_styles->registered[ $handle ];

        if ( empty( $style->src ) ) {
            continue;
        }

        $path = ABSPATH . $style->src;

        if ( $rtl && ! empty( $style->extra['rtl'] ) ) {
            // All default styles have fully independent RTL files.
            $path = str_replace( '.min.css', '-rtl.min.css', $path );
        }

        $content = file_get_contents( $path ) . "\n";

        if ( strpos( $style->src, '/' . WPINC . '/css/' ) === 0 ) {
            $content = str_replace( '../images/', '../' . WPINC . '/images/', $content );
            $content = str_replace( '../js/tinymce/', '../' . WPINC . '/js/tinymce/', $content );
            $content = str_replace( '../fonts/', '../' . WPINC . '/fonts/', $content );
            $out    .= $content;
        } else {
            $out .= str_replace( '../images/', 'images/', $content );
        }
    }

    header( "Etag: $fake_wp_version" );
    header( 'Content-Type: text/css; charset=UTF-8' );
    header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + $expires_offset ) . ' GMT' );
    header( "Cache-Control: public, max-age=$expires_offset" );

    echo $out;      // This is returned to browser with a set MIME type
    exit;  

5. Update the WP_Scripts and WP_Styles objects to use a fake WordPress version

WordPress uses two global objects to manage scripts and styles – WP_Scripts and WP_Styles, respectively. These add the WordPress version to the end of URLs, but you can set a custom version number and they’ll use it instead:

$wp_scripts = wp_scripts(); // Get the global object
$wp_scripts->default_version = $fake_version;
$wp_styles  = wp_styles();
$wp_styles->default_version  = $fake_version;

Handle Fingerprinting

The steps above will hide your WordPress version pretty effectively, but they’re quite a bit of work. And there’s still another way that people looking to break your site’s security can find the WordPress version, even if you follow all the steps above: fingerprinting. This involves comparing specific files (e.g. CSS or Javascript) from a known version of WordPress to the same file your site is using, then finding the WordPress version that way, a technique used by tools like WPScan. Our plugin handles this too.

Similar Posts

Leave a Reply