Prometheus metrics endpoint authentication
Read the bind address with netstat -ano | grep LISTENING, then request /metrics from an address that is not loopback. An exporter on its defaults answered from the machine's LAN address with 200 and 8390 bytes and asked for no credentials. Fixing it takes a token and a bind address.
Why check this
Run this whenever a service gains an exporter, and again after any change to the listen address, the container port mapping or the ingress rules. Instrumentation arrives through a library upgrade, and nobody files a ticket saying a new HTTP route was added.
The failure it prevents is a metrics port reachable from outside the cluster. There is nothing to exploit in the usual sense. There is a process start time, a runtime version, a memory figure, the list of internal route names and a business counter, refreshed every few seconds, for anyone who can open the port.
Prerequisites
- A service you run. This procedure is a read against your own host. Scanning another party's address range for an open
/metricsis not part of it. - Node 22, curl and
npm i prom-client. - The machine's own non-loopback address, from
node -e "console.log(require('os').networkInterfaces())". It is192.168.0.109in the output below. - The exporter, saved as
exporter.mjs. It carries the defaults that most exporters ship: every interface, no credentials.
// exporter.mjs node 22, ESM, prom-client 15.
// defaults: HOST=0.0.0.0 PORT=39119 and no token, the shape most exporters ship with.
import { createServer } from 'node:http';
import client from 'prom-client';
const host = process.env.HOST ?? '0.0.0.0';
const port = Number(process.env.PORT ?? 39119);
const token = process.env.METRICS_TOKEN;
client.collectDefaultMetrics();
const orders = new client.Counter({
name: 'orders_created_total', help: 'Orders created.', labelNames: ['plan'],
});
orders.inc({ plan: 'enterprise' }, 3);
createServer(async (req, res) => {
if (req.url !== '/metrics') return res.writeHead(404).end('no\n');
if (token && req.headers.authorization !== `Bearer ${token}`) {
res.writeHead(401, { 'WWW-Authenticate': 'Bearer realm="metrics"' });
return res.end('unauthorized\n');
}
res.writeHead(200, { 'Content-Type': client.register.contentType });
res.end(await client.register.metrics());
}).listen(port, host, () => console.log(`exporter on ${host}:${port}`));
- A note on what this target cannot show. A LAN address on one machine stands in for a reachable address. It says nothing about your firewall, your load balancer or your cluster network policy, each of which has to be read separately.
Steps
- Step 1.
Start the exporter with
node exporter.mjsand read what it bound to.netstat -ano | grep LISTENING | grep ":39119"TCP 0.0.0.0:39119 0.0.0.0:0 LISTENING 427040.0.0.0is every interface on the host, not localhost. This line is the whole question, and it is the one most teams never read. - Step 2.
Request the endpoint from the machine's own LAN address, with no credentials.
curl -s -o lan.txt -w "%{http_code} %{size_download} bytes\n" http://192.168.0.109:39119/metrics200 8390 bytesAnything that can route to this address gets the same 8390 bytes. No header was sent and none was required.
- Step 3.
Measure how much is on offer.
curl -s http://192.168.0.109:39119/metrics | awk '/^# TYPE/{n++} /^[a-z]/{s++} END{print n" metric names, "s" series"}'28 metric names, 86 seriesTwenty-eight names from a process that instruments one counter of its own. The rest arrived with
collectDefaultMetrics(). - Step 4.
Read four of those lines as a stranger would.
curl -s http://192.168.0.109:39119/metrics | grep -E '^(nodejs_version_info|process_start_time_seconds|process_resident_memory_bytes|orders_created_total)'process_start_time_seconds 1789198389 process_resident_memory_bytes 59977728 nodejs_version_info{version="v22.23.2",major="22",minor="23",patch="2"} 1 orders_created_total{plan="enterprise"} 3An exact runtime version to match against advisories, the last restart as a Unix timestamp, resident memory, and a live business counter. Poll it and you have the order rate.
- Step 5.
Start a second instance with a token,
METRICS_TOKEN=s3cr3t PORT=39120 node exporter.mjs, and request it the same way.curl -sS -D - -o /dev/null http://192.168.0.109:39120/metricsHTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="metrics" Date: Sat, 12 Sep 2026 07:39:22 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked401 with a challenge and no body worth reading. This is the state a scrape config with credentials still works against.
- Step 6.
Confirm the scraper's own request still succeeds.
curl -sS -o /dev/null -w "%{http_code} %{size_download} bytes\n" -H "Authorization: Bearer s3cr3t" http://192.168.0.109:39120/metrics200 8385 bytesBoth halves have to be checked. An endpoint that refuses everybody is a broken scrape, not a fix.
- Step 7.
Start a third instance bound to loopback,
HOST=127.0.0.1 PORT=39121 METRICS_TOKEN=s3cr3t node exporter.mjs, and try the LAN address again.curl -sS -o /dev/null -H "Authorization: Bearer s3cr3t" http://192.168.0.109:39121/metricscurl: (7) Failed to connect to 192.168.0.109 port 39121 after 2022 ms: Couldn't connect to serverRefused at the socket, with a valid token. The same request to
127.0.0.1:39121returns 200, so the process is up and only the address changed.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 0.0.0.0 in the netstat line | The process listens on every interface | Bind the metrics listener to loopback or to the internal interface. |
| 200 from a non-loopback address, no credentials | Anything that can route to the host can scrape | Add a token or mutual TLS, then repeat step 2. |
| 401 with WWW-Authenticate | The endpoint challenges unauthenticated callers | Confirm the scraper's credentialed request still returns 200. |
| curl: (7) from outside, 200 from loopback | The bind address is doing the work | Keep the token as well. Loopback is not a control once a sidecar shares the namespace. |
| Business counters in the body | The exposition carries product data, not only runtime data | Treat the endpoint as internal regardless of what else protects it. |
Common mistakes
What to check next
- How to check metrics endpoint: the functional read, before the exposure question.
- Prometheus text exposition format: the grammar of the body this page disclosed.
- Sensitive data in logs: the same review applied to the log stream.
- How to test a health check endpoint: the other unauthenticated route usually mounted next to this one.
FAQ
Should a Prometheus metrics endpoint require authentication?
Prometheus supports bearer tokens, basic auth and client certificates on a scrape job, so requiring one costs a few lines of scrape config. Pair it with a bind address: step 7 shows the socket refusing a caller who holds a valid token.
What does an exposed metrics endpoint disclose?
In the run above: the exact Node version, the process start time, resident memory, 28 metric names covering internal collectors, and a live business counter. Step 4 has the four lines.
Is binding to 127.0.0.1 enough?
It stops callers outside the host. It does not stop another container in the same network namespace, or anything else already on the box. Keep the credential as well.
How do I check this without scanning anyone?
Run it against a host you operate, from an address of your own. This procedure only ever requests the machine that started the exporter.
Does a 401 break scraping?
Only if the collector has no credential. Step 5 returns 401 to an anonymous request and step 6 returns 200 to the same URL with a bearer token.
Verified
Verified by Maks Vernynode 22.23.2curl 8.1.2prom-client 15.1.3
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny