
Application frameworks often add an X-Powered-By response header that names the runtime or framework, and sometimes its version, such as X-Powered-By: PHP/8.4.27, X-Powered-By: Express, or X-Powered-By: Next.js. The lasting change is to turn that header off in the application. If you cannot change the app, an nginx or Apache proxy can strip the header from responses it forwards. The sections below walk through each step with captured output.
Removing the header reduces one easy fingerprint. It does not stop determined fingerprinting. The Express production best practices put it this way: disabling the X-Powered-By header "does not prevent a sophisticated attacker from determining that an app is running Express. It may discourage a casual exploit, but there are other ways to determine an app is running Express." The same idea applies to the other stacks below.
Turn it off at the source
Start in the application or runtime that adds the header. The captures below were taken on this box against local PHP 8.4.27, Express 4.22.3, and Next.js 16.4.6 instances bound only to 127.0.0.1. We did not capture IIS.
PHP: expose_php
PHP adds the header when expose_php is on, which is the documented default. The current documentation says the setting exposes to the world that PHP is installed on the server, and that this includes the PHP version in the HTTP header. The directive is changeable in php.ini only, so a later ini_set in a script will not turn it off. Set it in the ini file that the SAPI actually loads, then reload:
expose_php = OffOn the PHP 8.4.27 built-in CLI server, a scripted 200 response started as:
$ curl -sSI http://127.0.0.1:18301/hello.php
HTTP/1.1 200 OK
Host: 127.0.0.1:18301
Date: Wed, 11 Nov 2026 17:10:41 GMT
Connection: close
X-Powered-By: PHP/8.4.27
Content-Type: text/plain; charset=utf-8After expose_php=Off, that 200 response no longer had the header, and a scripted 500 response was the same. Behind nginx, PHP-FPM 8.4.27 behaved the same way for those scripted 200 and 500 responses. A missing-script 404 from PHP-FPM also lost the header once expose_php was off. The built-in CLI server was different on its own 404 page; that case is covered below.
Express: app.disable('x-powered-by')
Express 4.22.3 sends X-Powered-By: Express by default. Turn it off on the application object:
app.disable('x-powered-by');In our tests, the default header was present on a 200 response, a 500 response, and Express's default 404 page. After app.disable('x-powered-by'), the header was gone on all three. The default 404 body still contained Cannot GET /missing, which is one of the other fingerprints the Express documentation mentions.
$ curl -sSI http://127.0.0.1:18303/
# Default
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
...
# After app.disable('x-powered-by')
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
...Next.js: poweredByHeader
Next.js adds the header by default. The poweredByHeader documentation shows how to opt out in next.config.js:
module.exports = {
poweredByHeader: false,
}On Next.js 16.4.6, a 200 page, a 404 page, and a 500 page that threw at request time each sent X-Powered-By: Next.js with the default still on. After poweredByHeader: false, those three responses no longer had the header. A Route Handler that returned HTTP 500 did not send X-Powered-By even with the default still on. Check the responses you actually serve, not only the home page.
In our tests, even after poweredByHeader: false, the 200 response still included fields such as x-nextjs-cache and x-nextjs-prerender, and a Vary value that names Next.js router headers. Removing X-Powered-By does not remove those other fingerprints.
IIS and ASP.NET, from Microsoft documentation
We did not capture IIS or ASP.NET. The official IIS custom headers documentation shows that the default ApplicationHost.config customHeaders collection includes <add name="X-Powered-By" value="ASP.NET" />, and that a remove element can remove a custom header from that collection. In a site web.config file, that takes this form:
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
</customHeaders>
</httpProtocol>
</system.webServer>This is from Microsoft's documentation, not a result we tested. Confirm the public header on your own IIS site after the change.
Strip it at the proxy when you can't change the app
When you cannot change the application, a reverse proxy can drop the header from responses it forwards. The lasting fix is still to turn it off at the source where you can, because another path to the origin may bypass the proxy.
In nginx 1.26.3, proxy_hide_header sets additional fields that will not be passed from a proxied HTTP upstream to the client. In our test, a loopback upstream sent X-Powered-By: ExampleFramework/4.2. Without the hide directive, both the 200 and the 500 response showed the header. After proxy_hide_header X-Powered-By;, the header was gone on both. The Server: nginx header that nginx adds itself was unchanged. For hiding that Server header, see Hide the Nginx Server Header.
server {
listen 8080;
server_tokens off;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_hide_header X-Powered-By;
}
}$ curl -sSI http://127.0.0.1:18313/
# Before proxy_hide_header
HTTP/1.1 200 OK
Server: nginx
...
X-Powered-By: ExampleFramework/4.2
# After proxy_hide_header X-Powered-By;
HTTP/1.1 200 OK
Server: nginx
...For FastCGI upstreams, including PHP-FPM, the matching directive is fastcgi_hide_header. We verified it against PHP-FPM 8.4.27, which sent X-Powered-By: PHP/8.4.27. After fastcgi_hide_header X-Powered-By;, that header was absent on both the 200 and the 500 response, and Server: nginx was still present.
location ~ \.php$ {
# PHP-FPM often listens on 127.0.0.1:9000 or a unix socket
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_hide_header X-Powered-By;
}In Apache, mod_headers can remove a header that is already present, with Header unset or Header always unset. The two forms are not interchangeable, and the next section covers the difference we captured on Apache/2.4.69 (Debian). If you also want to hide Apache's own Server header, see Hide the Apache Server Header.
Why some removals only work on some responses
A change that looks complete on a 200 home page can leave the header in place on an error response, or on a response that took a different path through the server. Check more than one status, and check the kind of response you actually serve.
The official Apache Header documentation says the optional condition is onsuccess (the default) or always, and that always is not a superset of onsuccess with respect to existing headers. It also notes that headers generated by a CGI script live in the always table. We tested both forms against a CGI script and against a proxied HTTP upstream that each sent X-Powered-By: ExampleFramework/4.2, on both 200 and 500 responses.
For the CGI responses, Header unset X-Powered-By left the header in place on both the 200 and the 500 response. Header always unset X-Powered-By removed it on both.
# CGI, Header unset X-Powered-By
HTTP/1.1 200 OK
Server: Apache
X-Powered-By: ExampleFramework/4.2
...
# CGI, Header always unset X-Powered-By
HTTP/1.1 200 OK
Server: Apache
...For the proxied HTTP responses, the result was the other way around. Header unset X-Powered-By removed the header on both the 200 and the 500 response, and Header always unset X-Powered-By left it in place on both. These are the results from this Apache/2.4.69 (Debian) capture, not a claim about every module or version.
Using both directives together removed X-Powered-By in every Apache case we tried: CGI 200, CGI 500, proxied HTTP 200, and proxied HTTP 500.
Header unset X-Powered-By
Header always unset X-Powered-ByPHP showed a similar split on one SAPI only. On the PHP 8.4.27 built-in CLI server, expose_php=Off removed the header from scripted 200 and 500 responses, but the server's own 404 page still sent X-Powered-By: PHP/8.4.27. The same Off setting on PHP-FPM 8.4.27, behind nginx, removed the header on a 200, a 500, and a missing-script 404 that returned File not found. These are results from this build, not a claim about every SAPI or version.
Next.js 16.4.6 added a smaller split: a Route Handler that returned HTTP 500 did not send X-Powered-By even with the default still on, while a 500 page that threw at request time did send X-Powered-By: Next.js. nginx proxy_hide_header and fastcgi_hide_header removed the header on both the 200 and the 500 responses we captured. The safe check is still to request a successful page and at least one error response, then read the public headers.
- 1
Application
Where X-Powered-By is usually added
- PHP 8.4.27 sent X-Powered-By: PHP/8.4.27 until we set expose_php=Off.
- Express 4.22.3 sent X-Powered-By: Express until we used app.disable('x-powered-by').
- Next.js 16.4.6 sent X-Powered-By: Next.js until we set poweredByHeader: false.
- IIS documents a default X-Powered-By: ASP.NET custom header that can be removed in web.config. We did not capture IIS.
- 2
Origin web server
Can strip a header the application already set
- In Apache, Header unset and Header always unset are not interchangeable.
- In our tests on Apache/2.4.69 (Debian), Header unset left the header on CGI 200 and 500 responses, and removed it on proxied HTTP 200 and 500 responses. Header always unset did the opposite.
- Using both directives together removed the header in every Apache case we tried.
- 3
Reverse proxy
Optional hop in front of the app
- nginx proxy_hide_header X-Powered-By removed the upstream header on both 200 and 500 responses in our tests on nginx 1.26.3.
- fastcgi_hide_header X-Powered-By did the same for PHP-FPM 8.4.27 on 200 and 500 responses.
- These directives apply to headers received from the upstream. They do not remove headers the proxy adds itself, such as Server.
- 4
Client
Sees the public response
- Verify with curl against the public hostname, including any proxy or CDN in front of the application.
- Check a successful response and at least one error response. In our tests, some removals did not apply to every status or every kind of response.
Check your X-Powered-By response headers
Paste raw curl -I, HEAD or GET response output, including the HTTP status line. For curl -IL, each response is shown separately, in pasted order. These are response blocks, not proof of distinct network hops.
Pasted text stays in your browser. Nothing is sent or stored.
Only pasted text is checked, not your live site. A truncated capture may omit fields. Check successful, redirect and error responses separately, including GET bodies. Removing this header reduces one easy fingerprint. It is not security assurance.
Verify with curl
After you change the application or the proxy, request a normal page and read the X-Powered-By line:
$ curl -sI https://your-site/
HTTP/1.1 200 OK
...Also request a page that does not exist, and a URL that returns an error if you have one, including the body:
$ curl -sSi https://your-site/does-not-existIf you changed PHP-FPM, reload the FPM service after the ini change. If you changed nginx, run nginx -t and reload. If you changed Apache, run a config test and reload, as in Verify with curl on the Apache Server header guide. Repeat the check through any reverse proxy or CDN in front of the origin, because that layer can add or replace headers after the application has set them. You can paste the same curl output into the response header checker on this page.

Have a system you want tested?
Tell us what you are working with and we will follow up with next steps.