Removing X-Powered-By and Server Headers Safely

This guide is part of the Deprecated Security Headers & Legacy Browser Support reference. Stripping X-Powered-By, Server, and other version-disclosing headers reduces reconnaissance value for an attacker — it removes the exact framework and runtime version that maps a target to a published exploit. It is a hygiene control, not a security boundary: it never substitutes for patching, HSTS, or a strict Content Security Policy. The complication is that these headers are injected at different layers, and a value you suppress at the app can be re-added by a proxy or CDN one hop later.

Where version-disclosing headers are injected across the stack A left-to-right request path from client through CDN, reverse proxy, web server, and application framework, labelling which version-disclosing header each layer injects. Client CDN Server: cloudflare Proxy Server: nginx Web server Server: Apache App / framework X-Powered-By Each layer can add or overwrite a version-disclosing header Suppress at every hop the response actually traverses
The header you strip at the app is re-added by the proxy or CDN unless suppressed at each hop the response traverses.

Configuration Syntax & Exact Values

There is no header to set — the work is to delete or blank what each layer injects. The targets:

Header Injected by Discloses
Server web server / reverse proxy / CDN server software and often its version
X-Powered-By application framework (Express, PHP, ASP.NET) framework / runtime and version
X-AspNet-Version ASP.NET .NET runtime version
X-AspNetMvc-Version ASP.NET MVC MVC version

Note that most web servers cannot fully remove their own Server header without an extra module — they can only collapse it to a versionless string (Server: nginx, Server: Apache). Full removal is called out per platform below.

Because the same header name can originate at any hop, the first diagnostic question is never how do I delete it but which component set the value the browser is showing. That answer picks the tool: an app flag, a web-server directive, an upstream-stripping directive, or an edge rule. Suppressing at the wrong layer looks like a fix in a local test and silently fails in production, because a hop closer to the client re-adds the value.

Deciding which layer to strip the header atA decision tree that routes a visible version header to the correct suppression mechanism based on which layer injected it. Header visible in response Which layer injected it? app server core upstream CDN App framework app.disable Web server core server_tokens off Upstream proxy proxy_hide_header CDN edge Transform Rule Decisive hop = the one closest to the client but strip at every hop the response traverses
Route a visible header to the right fix by asking which layer set it — the browser only ever shows the value written by the hop closest to it.

Server-Side Configuration

The web server is where the Server header itself lives, and the platforms differ sharply in how far they let you go. Two directives (server_tokens off, ServerTokens Prod) collapse the version but leave a bare product string; genuine removal needs an extra module on Nginx and Apache, while IIS 10 can drop the string outright. The matrix below maps each platform to the exact mechanism for both the Server string and X-Powered-By.

Header suppression capability by platformA matrix showing which directive collapses the Server version, which mechanism removes it entirely, and how each platform strips X-Powered-By. What each layer can actually suppress Platform Collapse Server Remove Server Strip X-Powered-By Nginx server_tokens off headers_more proxy_hide_header Apache ServerTokens Prod mod_security Header unset Cloudflare fixed: cloudflare not removable Transform Rule Node / IIS none / removed IIS 10 only app.disable / remove
Only IIS 10 and module-augmented Nginx/Apache can delete the Server string; everywhere else the realistic goal is collapsing the version.

Nginx

http {
    # Collapses "Server: nginx/1.25.3" to "Server: nginx" and removes version
    # strings from default error pages.
    server_tokens off;

    server {
        listen 443 ssl;
        server_name example.com;

        location / {
            proxy_pass http://127.0.0.1:3000;
            # Strips the named headers from the UPSTREAM app response before
            # forwarding to the client.
            proxy_hide_header X-Powered-By;
            proxy_hide_header Server;
        }
    }
}

To remove Nginx’s own Server header entirely (not just collapse it), build with the ngx_http_headers_more_filter_module and add more_clear_headers Server;. proxy_hide_header only affects headers the upstream sets; it does not touch the header Nginx generates itself.

Apache

# Requires mod_headers: LoadModule headers_module modules/mod_headers.so
ServerTokens Prod
ServerSignature Off
Header always unset X-Powered-By
Header always unset X-AspNet-Version

ServerTokens Prod reduces Server to Apache with no version or OS. Header always unset removes the named header on all responses including error pages — the always keyword is what makes it apply to 4xx/5xx, where the plain form is skipped. Apache cannot drop its minimal Server: Apache string without mod_security (SecServerSignature) or patching the binary.

Cloudflare

Cloudflare appends Server: cloudflare to every proxied response and that value cannot be removed (it identifies the edge). What you control is everything the edge forwards from origin. Create a Transform Rule (Modify Response Header) with Remove actions:

Rule: Strip version-disclosing headers
When: hostname equals example.com
Then: Remove response header "X-Powered-By"
      Remove response header "X-AspNet-Version"
      Remove response header "X-AspNetMvc-Version"

This removes the headers at the edge before client delivery, regardless of what origin emits. The Server: cloudflare string remains and is expected.

Node / Helmet

const express = require('express');
const helmet = require('helmet');
const app = express();

// Native Express flag — removes the default "X-Powered-By: Express" header.
app.disable('x-powered-by');

// Equivalent via Helmet, for codebases that centralize header policy there.
app.use(helmet.hidePoweredBy());

app.listen(3000);

app.disable('x-powered-by') and helmet.hidePoweredBy() do the same thing; use one, not both. Node’s HTTP server does not send a Server header by default, so there is usually nothing to strip there — if one appears, an upstream proxy added it (suppress it at that proxy, per the Nginx block above).

PHP

; php.ini — stops PHP appending "X-Powered-By: PHP/8.3.2" to every response
expose_php = Off

expose_php = Off removes the X-Powered-By: PHP/x.y.z header the interpreter attaches automatically. The single most common reason this appears to “not work” is editing the wrong php.ini — the CLI file rather than the one your FPM or CGI pool loads. Confirm the active file with php --ini (or a phpinfo() page for the web SAPI specifically), change that file, and reload the pool: systemctl reload php8.3-fpm. Note that application frameworks layered on top (Laravel, Symfony, WordPress plugins) can set their own disclosure headers after PHP’s, so grep -R "X-Powered-By" . across the codebase before declaring the header gone.

IIS / ASP.NET

<system.webServer>
  <httpProtocol>
    <customHeaders>
      <remove name="X-Powered-By" />
    </customHeaders>
  </httpProtocol>
  <security>
    <requestFiltering removeServerHeader="true" />
  </security>
</system.webServer>

removeServerHeader="true" (IIS 10 and later) deletes the Server: Microsoft-IIS/10.0 string entirely — IIS is the one mainstream server that drops its own Server header without a third-party module. The <remove> element strips the X-Powered-By: ASP.NET header IIS injects. The version headers are separate: set <httpRuntime enableVersionHeader="false" /> under <system.web> to drop X-AspNet-Version, and add MvcHandler.DisableMvcResponseHeader = true; to Application_Start to drop X-AspNetMvc-Version. After editing web.config, recycle the application pool so the changes take effect on every worker process.

Diagnostic & Verification Steps

Verify against both the public hostname and, where possible, the origin directly — caching layers serve stale headers until purged.

# Through the public edge:
curl -sI https://example.com/ | grep -iE '(server|x-powered-by|x-aspnet)'

Expected output (Cloudflare in front, app headers stripped):

server: cloudflare

x-powered-by and x-aspnet-version must produce no lines. Behind a CDN you cannot remove, Server: cloudflare is the expected and correct result.

# Direct origin check, bypassing the CDN edge:
curl -sI -H 'Host: example.com' https://203.0.113.10/ | grep -iE '(server|x-powered-by)'

Expected output: empty (or server: nginx with no version if you only ran server_tokens off).

In DevTools → Network, select the document request, enable Disable cache, and read the Response Headers pane. A header that survives here but not at the origin is being added by an intermediate hop. Run the check across GET, POST, and OPTIONS — a framework can attach X-Powered-By to one method’s responses and not another’s.

The safe order is staging first, both curl checks before promotion, and the edge rule last so you never strip a header the edge is still fingerprinting on. The sequence below is the one to follow end to end.

Rollout and verification orderA five-step timeline from applying configuration in staging through verifying origin and edge to re-testing across HTTP methods in production. Apply and verify in this order Staging Verify origin Verify edge Promote + CDN Re-test 1 2 3 4 5 apply configs curl -sI origin curl -sI edge add Transform Rule GET / POST / OPTIONS
Verify at the origin before you promote, and add the edge rule last — the order that never leaves a hop unchecked.

Edge Cases, Security Implications & Safe Rollback

Frequently Asked Questions

Why can’t I fully remove the Server header on Nginx or Apache? Both servers generate the Server header in core code. The standard directives (server_tokens off, ServerTokens Prod) only strip the version, leaving a bare nginx or Apache. Full removal needs an extra module — headers_more for Nginx, mod_security for Apache.

Does removing these headers actually stop attackers? It removes a fast path. Attackers can still fingerprint a server via TLS cipher order, HTTP/2 SETTINGS frames, error-page styling, and response timing. Treat suppression as hygiene that slows automated scanning, not as a defense against a targeted attacker.

My CDN still shows Server: cloudflare — did I fail? No. The CDN’s own Server value identifies the edge and cannot be removed. Confirm that the origin-injected headers (X-Powered-By, X-AspNet-Version) are gone; the CDN’s own Server string is expected.

Where should I strip the header — origin or edge? At every hop the response traverses, but the decisive one is the hop closest to the client, because that is the value the browser receives. Strip at origin for direct traffic and at the CDN/proxy for edge traffic.

Conclusion

Roll this out incrementally: apply server_tokens off / ServerTokens Prod and the app-level disable in staging first, verify with curl -sI against both the origin and the edge, then promote to production and add the CDN Transform Rule. Because the change is subtractive and non-destructive, it carries no enforcement risk — the only diligence required is confirming that no WAF or telemetry system silently depended on the disclosed version before you remove it.