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.

Server-Side Configuration

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).

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.

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.