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

// 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}`));

Steps

  1. Step 1.

    Start the exporter with node exporter.mjs and read what it bound to.

    netstat -ano | grep LISTENING | grep ":39119"
    
      TCP    0.0.0.0:39119          0.0.0.0:0              LISTENING       42704

    0.0.0.0 is every interface on the host, not localhost. This line is the whole question, and it is the one most teams never read.

  2. 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/metrics
    
    200 8390 bytes

    Anything that can route to this address gets the same 8390 bytes. No header was sent and none was required.

  3. 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 series

    Twenty-eight names from a process that instruments one counter of its own. The rest arrived with collectDefaultMetrics().

  4. 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"} 3

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

  5. 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/metrics
    
    HTTP/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: chunked

    401 with a challenge and no body worth reading. This is the state a scrape config with credentials still works against.

  6. 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/metrics
    
    200 8385 bytes

    Both halves have to be checked. An endpoint that refuses everybody is a broken scrape, not a fix.

  7. 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/metrics
    
    curl: (7) Failed to connect to 192.168.0.109 port 39121 after 2022 ms: Couldn't connect to server

    Refused at the socket, with a valid token. The same request to 127.0.0.1:39121 returns 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

Sign: The team is certain the port is internal because the service is internal.Cause: The application listener and the metrics listener are often the same socket, and the default in most frameworks is every interface. Step 1 shows 0.0.0.0 from a server nobody configured to be public.
Sign: Authentication is added, and nobody notices the scrape has stopped.Cause: A metrics target that returns 401 to the collector fails silently, because the dashboard keeps drawing the last stored points for a while. Run step 6 with the collector's own credentials after every change to the endpoint.
Sign: Only the runtime metrics are reviewed for sensitivity.Cause: Default collectors are predictable, so the interesting line is the application's own. `orders_created_total` polled twice a minute is an order rate feed. Read the custom metric names before deciding the endpoint is dull.

What to check next

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.

intermediate10 minpublished updated Maks Verny