# Server and X-Powered-By headers: why version numbers matter

> Apache/2.4.29, PHP/7.2.24, Express. Two response headers hand every scanner your exact software versions. What they reveal, what hiding them does and does not achieve, and the fix for each server.

Updated 2026-09-25 · Security · HTML version: https://getreport.app/guides/server-and-x-powered-by-headers-version-numbers

Ask any web server for a page and it introduces itself: `Server: Apache/2.4.29 (Ubuntu)`, `X-Powered-By: PHP/7.2.24`. Those two lines cost nothing to send and nothing to remove, and they are the first thing an automated scanner reads before deciding whether your site is worth attacking. This guide shows what the headers reveal, what removing them buys you (less than patching, more than nothing), and the exact setting for each server, in about fifteen minutes.

## Quick answer

- **Apache**: `ServerTokens Prod` in the main config (`httpd.conf` or `apache2.conf`; it does not work in `.htaccess`) and `ServerSignature Off`. Result: `Server: Apache`.
- **nginx**: `server_tokens off;` in the `http` block. Result: `Server: nginx`.
- **PHP**: `expose_php = Off` in `php.ini`. Removes `X-Powered-By: PHP/x.y.z`.
- **Express**: `app.disable('x-powered-by')`. **IIS/ASP.NET**: remove the headers in `web.config`.
- **Caddy** sends `Server: Caddy` with no version; **Cloudflare** replaces `Server` with `cloudflare` at the edge.
- Then patch anyway. A hidden version is still that version. Re-run the [security headers checker](https://getreport.app/tools/security-headers) to confirm.

## Why version numbers matter

An attacker with a list of a million domains does not read them one by one. A script fetches the home page of each, reads the `Server` and `X-Powered-By` headers, and files the site under "Apache 2.4.29" or "PHP 7.2". Then a second script looks up which published vulnerabilities (CVEs) apply to those versions and tries only those. Your headers do the sorting for them. This is free reconnaissance, and it is exactly how mass exploitation campaigns pick their targets: not by interest in your site, but by matching a version string.

What the two headers typically say:

| Header | Example value | What an attacker learns |
| --- | --- | --- |
| `Server` | `Apache/2.4.29 (Ubuntu)` | Web server, exact version, operating system family, so probably Ubuntu 18.04 and its package set |
| `Server` | `nginx/1.18.0` | nginx from 2020, likely a distribution package that is behind upstream |
| `Server` | `Microsoft-IIS/10.0` | Windows Server 2016 or later |
| `X-Powered-By` | `PHP/7.2.24` | A PHP branch that stopped receiving security fixes in November 2020 |
| `X-Powered-By` | `ASP.NET` plus `X-AspNet-Version: 4.0.30319` | .NET Framework version |
| `X-Powered-By` | `Express` | Node.js with Express; no version, still a hint for which exploits to try |

Notice that `X-Powered-By: PHP/7.2.24` is worse than the Apache line: PHP 7.2 is out of support, so the header does not just narrow the search, it announces an unpatched runtime.

### What hiding them does not do

Removing the header does not update PHP. A scanner that cannot read the version can still probe for the vulnerability directly, and many do, because probing is cheap. Fingerprinting also has other routes: the order and format of the remaining headers, error page markup, `ETag` format, the PHP session cookie name `PHPSESSID`, and for WordPress the `?ver=` query strings on core scripts.

So the honest framing: hiding the version removes your site from the easy list. It buys you a place in the "unknown, try later" pile instead of the "PHP 7.2, exploit now" pile. That is worth ten minutes. It is not worth skipping the update.

## How getReport checks it

> **Free tool:** [Security headers checker](https://getreport.app/tools/security-headers): Check HSTS, CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy and cookie flags on any site. Free, no signup, with a fix for every missing header.

The checker fetches the page like a browser, follows redirects, and reads the response headers of the final page. Two findings come from the version headers, and the rules are simple:

- **`Server`** warns when the value carries a version-like number: a slash followed by a digit (`nginx/1.18.0`, `Microsoft-IIS/10.0`) or digits with a dot (`Apache 2.4`). A bare product name (`Apache`, `nginx`, `cloudflare`, `LiteSpeed`) passes, and so does no `Server` header at all.
- **`X-Powered-By`** warns whenever the header exists, with or without a version. `X-Powered-By: Express` is a warning too, because the header has no legitimate use for the visitor; the right fix is always to remove it.

> **Check: Server header does not reveal a version.** A version string such as Apache/2.4.29 tells automated scanners exactly which known vulnerabilities to try. Hiding it costs nothing and breaks nothing.
>
> 1. Apache: ServerTokens Prod; nginx: server_tokens off; IIS: remove the header in web.config.
> 2. Behind Cloudflare or a load balancer, strip the header there.

> **Check: No X-Powered-By header is sent.** X-Powered-By names the language and often its exact version to anyone who asks. Together with the Server header it is a ready-made target list.
>
> 1. PHP: expose_php = Off in php.ini; Express: app.disable("x-powered-by"); ASP.NET: remove the header in web.config.

![The X-Powered-By finding opened on a page whose server announces PHP: the finding title, the evidence line reading x-powered-by: PHP/7.2.24, the explanation of why a runtime version is a target list, and the fix lines for PHP, Express and ASP.NET](https://getreport.app/guides/img/server-and-x-powered-by-headers-version-numbers/version-leak.webp "The evidence line shows the exact value the server sent; the fix names the setting for each runtime.")

A third finding in the same module reads the version a CMS prints into the HTML rather than the headers. WordPress, Joomla and Drupal all announce themselves in a `<meta name="generator">` tag, and the check compares that version with the current release:

> **Check: CMS version.** Old CMS versions have published security holes that bots scan for automatically, and the generator tag advertises the exact version to them.
>
> 1. Back up the site, then update the CMS from its admin dashboard; update plugins and themes at the same time.
> 2. Remove the generator tag (WordPress: remove_action('wp_head', 'wp_generator') in functions.php).

Both kinds of leak are worth fixing, but note the difference in weight: an old CMS version is a real, exploitable condition; a version header is a signpost to one. Update first, then hide.

## Step by step

### 1. See what you send today

From a terminal, from outside your own network so that any CDN or cache is included:

```bash
curl -sI https://example.com/ | grep -i -E "^(server|x-powered-by|x-aspnet-version|x-generator):"
```

Also check an error page, because Apache and nginx print the version on their default 404 and 500 pages independently of the headers:

```bash
curl -s https://example.com/this-page-does-not-exist | grep -i -E "apache|nginx|php"
```

### 2. Apache: ServerTokens and ServerSignature

`ServerTokens` is only valid in the server configuration, not in `.htaccess` (`ServerSignature` works in both, but keep them together). On Debian and Ubuntu that is `/etc/apache2/conf-enabled/security.conf`; on RHEL-family systems `/etc/httpd/conf/httpd.conf`:

```apache
# Server: Apache  (no version, no OS, no modules)
ServerTokens Prod
# No "Apache/2.4.29 (Ubuntu) Server at example.com Port 443" footer on error pages
ServerSignature Off
```

Reload Apache (`sudo systemctl reload apache2`). Apache cannot drop the `Server` header entirely without a third-party module; `Apache` alone passes the check and tells a scanner nothing it could not guess.

If PHP runs inside Apache or behind it and you cannot change `php.ini` yet, `mod_headers` can strip the runtime header from every response, including in `.htaccess`:

```apache
Header always unset X-Powered-By
```

### 3. PHP: expose_php

`expose_php` is a system-level setting: it can only be changed in `php.ini` (or in the PHP-FPM pool config), never in `.htaccess` or with `ini_set()`. On most hosting panels it is under "PHP options" or "PHP configuration"; on a server you manage, find the loaded file with `php --ini` and set:

```text
; php.ini
expose_php = Off
```

Restart PHP-FPM (`sudo systemctl restart php8.3-fpm`, adjust to your version) or reload Apache for mod_php. This removes `X-Powered-By` from every response PHP generates, whichever web server sits in front.

### 4. nginx: server_tokens

In `/etc/nginx/nginx.conf`, inside the `http` block:

```nginx
server_tokens off;
```

Then `sudo nginx -t && sudo systemctl reload nginx`. The header becomes `Server: nginx` and the version disappears from error pages too. Removing the header altogether needs the `headers-more` module (`more_clear_headers Server;`), which is packaged on Debian and Ubuntu as `libnginx-mod-http-headers-more-filter`. `nginx` alone already passes the check; do not compile a module for this.

When nginx proxies to PHP-FPM or a Node app, it passes their headers through. `expose_php = Off` and `app.disable('x-powered-by')` fix the source; if you cannot reach it, strip at the proxy:

```nginx
location / {
    proxy_pass http://app:3000;
    proxy_hide_header X-Powered-By;
}
```

For PHP-FPM via `fastcgi_pass` the equivalent is `fastcgi_hide_header X-Powered-By;`.

### 5. Caddy, Express, IIS

**Caddy** sends `Server: Caddy` without a version and no `X-Powered-By`, so a Caddy site passes both checks with no change. To drop the header entirely, add `header -Server` inside the site block.

**Express** sets `X-Powered-By: Express` by default. In the app setup:

```js
const express = require('express');
const app = express();
app.disable('x-powered-by');
```

The `helmet` package does the same and adds the other security headers in one call.

**IIS and ASP.NET**, in `web.config`:

```xml
<configuration>
  <system.webServer>
    <security>
      <requestFiltering removeServerHeader="true" />
    </security>
    <httpProtocol>
      <customHeaders>
        <remove name="X-Powered-By" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
  <system.web>
    <httpRuntime enableVersionHeader="false" />
  </system.web>
</configuration>
```

`removeServerHeader` needs IIS 10; `enableVersionHeader="false"` removes `X-AspNet-Version`.

### 6. Cloudflare and other CDNs

Cloudflare replaces the origin's `Server` header with `Server: cloudflare` on every proxied response, so the `Server` finding passes as soon as the orange cloud is on. `X-Powered-By` is passed through from the origin unless you remove it: Rules, Settings, Managed Transforms, switch on "Remove X-Powered-By headers", or a Transform Rule that removes the header by name. Other CDNs behave differently; test with `curl` against the public hostname, not the origin IP.

## Platform notes

### WordPress

WordPress reveals its version in four places, and the headers are not among them:

1. **The generator tag** in every page's `<head>`: `<meta name="generator" content="WordPress 6.8">`. This is what the `outdated-cms-version` check reads.
2. **RSS and Atom feeds**, which carry a `<generator>` element with the version.
3. **`readme.html`** in the site root, which prints the version in its heading. It is restored by every core update, so a server rule is better than deleting it.
4. **`?ver=6.8`** on core stylesheet and script URLs, and `?ver=1.2.3` on each plugin's and theme's assets, which reveals plugin versions too.

One filter covers the first two; add it to a must-use plugin at `wp-content/mu-plugins/no-generator.php`:

```php
<?php
/**
 * Plugin Name: Remove version disclosure
 */
// Generator tag in <head> and in feeds
add_filter('the_generator', '__return_empty_string');
```

`remove_action('wp_head', 'wp_generator')` does the head tag only; the filter does both. Block `readme.html` at the server:

```nginx
location = /readme.html { return 404; }
```

```apache
<Files "readme.html">
    Require all denied
</Files>
```

Be honest with yourself about the `?ver=` strings: stripping them from asset URLs breaks cache busting after updates, and the version is still discoverable from the file contents. The real fix for a WordPress version leak is to be on the current version; then the generator tag advertises nothing an attacker can use. The [WordPress checker](https://getreport.app/tools/wordpress-checker) reads the same passive signals a scanner would.

### Shopify, Wix, Squarespace and other hosted platforms

The headers are set by the platform and are usually already clean (`Server: cloudflare` or a bare product name). There is nothing for you to change; run the checker to confirm.

### Shared hosting with cPanel or Plesk

`ServerTokens` and `server_tokens` are set by the host, not you; many hosts already send a bare `Server: Apache` or `LiteSpeed`. `expose_php` is usually editable under "Select PHP Version" or "PHP Settings" in the panel. If the `Server` header still shows a version, ask support; it is a one-line change on their side.

## Verify

1. Re-run the [security headers checker](https://getreport.app/tools/security-headers). Both findings should read "Server header does not reveal a version" and "No X-Powered-By header is sent".
2. `curl -sI https://example.com/ | grep -i -E "^(server|x-powered-by):"` shows `Server: nginx` (or `Apache`, `cloudflare`, `Caddy`) and no `X-Powered-By` line.
3. `curl -s https://example.com/this-page-does-not-exist | grep -i -E "apache/|nginx/"` prints nothing: error pages no longer carry the version.
4. For WordPress, `curl -s https://example.com/ | grep -i generator` prints nothing, and `curl -sI https://example.com/readme.html` answers 404 or 403.

## Common mistakes

- **Putting `ServerTokens Prod` in `.htaccess`.** Apache answers 500 Internal Server Error for the whole site, because the directive is not allowed there. Move it to the main config and reload.
- **Setting `expose_php` with `php_flag` or `ini_set()`.** Silently ignored; the header stays. It is `php.ini` or the FPM pool config only.
- **Hiding the version and calling the site patched.** The scanner that reads headers is the lazy one. Update PHP, the web server and the CMS; the version header is the last step, not a replacement. The generator side of this is covered in [Outdated CMS versions: reading the generator tag](https://getreport.app/guides/outdated-cms-versions-reading-the-generator-tag).
- **Testing from the server itself.** `curl localhost` bypasses Cloudflare and the cache, so you see the origin's headers, not the visitor's. Test the public hostname from outside.
- **Stopping at the two headers.** The same reconnaissance pass looks for `/.env`, `/.git/HEAD`, `phpinfo.php` and open directory listings, which reveal far more than a version. Those are separate findings in the same module; see [Directory listings and exposed files](https://getreport.app/guides/directory-listings-and-exposed-files). The rest of the header set is in [Security headers from zero to A](https://getreport.app/guides/security-headers-from-zero).
