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.
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
- A proxy re-adds the header. You stripped
X-Powered-Byat the app, but the response still shows it because the reverse proxy or CDN injects its own, or forwards the upstream value. Fix: suppress at the hop closest to the client —proxy_hide_headerin Nginx, a Transform Rule remove in Cloudflare — since that is what the browser sees. - A WAF fingerprints on the header. A legacy WAF or ModSecurity rule chain keys off
Server/X-Powered-Byfor version-based signature matching, and stripping the header causes false negatives or breaks the rule. Fix: migrate the WAF to URI-, body-, or behavior-based rules and a positive (allow-list) model before stripping; monitor the WAF audit log for403regressions during the change window. - APM/telemetry depends on the header. Monitoring tools correlate runtime version from
X-Powered-By. Fix: emit the version into structured server-side logs ({"runtime":"node-20.11"}) rather than a client-visible header. - Rollback. This change is subtractive and non-destructive — restoring disclosure is rarely necessary, but if a downstream system genuinely breaks, revert with
server_tokens on;,ServerTokens Full, re-enable the Cloudflare rule, or removeapp.disable('x-powered-by'), then reload the service and purge the CDN cache before re-testing.
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.
Related
- Deprecated Security Headers & Legacy Browser Support — the parent reference for retiring legacy headers.
- Content Security Policy (CSP) Essentials — the control that actually mitigates injection, unlike header suppression.
- HTTP Strict Transport Security (HSTS) Deep Dive — transport hardening that belongs alongside fingerprint reduction.