Every service you run exposes a handful of endpoints nobody designed: a health check, a metrics scrape, a readiness probe, and whatever your framework's management module turns on by default. They exist for the platform rather than for users, they were configured once, and they are frequently reachable from further away than anyone intended.
They are also the most informative endpoints on the service, which is the problem.
This post is what they give away and how to expose them properly. For whoever runs services behind a load balancer.
What a health endpoint typically reveals
The minimal version returns a status and nothing else. The default version in several frameworks returns considerably more:
- The status of each dependency, by name: which database, which cache, which downstream services, sometimes with hostnames.
- Version information for the application and its framework, which tells an attacker exactly which advisories to look up.
- Build metadata: commit hash, build time, branch.
- Configuration, in the worst cases, from a management endpoint that dumps effective settings.
None of this is a vulnerability on its own. Together it is a map: what you run, what version, what it talks to, and what is currently broken. The last one matters more than it looks, because a partially degraded system is easier to attack than a healthy one, and your health endpoint announces degradation publicly.
Metrics endpoints are worse
A metrics scrape is designed to be comprehensive, and it usually is:
- Request counts by route, which enumerates your entire API including the endpoints not in your documentation.
- Error rates by type, which tells someone probing you whether their input caused an exception.
- Queue depths, connection pool saturation, cache hit rates: a live view of where the pressure is.
- Business metrics, if anyone added them, which can include signup counts and revenue figures.
The route enumeration alone is worth an afternoon of reconnaissance, delivered in one request.
The separation that fixes it
Serve operational endpoints on a different port, bound to an internal interface, not routed by your public load balancer.
# example: application on 8080, management on 8081
server.port: 8080
management.server.port: 8081
management.server.address: 127.0.0.1
management.endpoints.web.exposure.include: health,prometheus
That last line matters: expose the endpoints you need rather than accepting the default set, which in several frameworks includes environment, configuration and thread dumps.
On Kubernetes, the probe reaches the pod directly, so a management port bound to the pod's interface works for liveness and readiness while remaining unreachable through the ingress. Scrapers reach it the same way.
If you genuinely cannot split the port, authenticate the paths, and confirm the rule covers every variant your framework serves rather than the one you tested.
Two levels of health
The split that makes this easy is having two endpoints with different audiences.
A shallow one for the load balancer: is this process able to serve. Returns a status code and an empty body. Safe to expose, cheap to call, and it should not check dependencies, because a load balancer removing every instance when the database blips turns a degraded system into an outage.
A deep one for humans and dashboards: check dependencies, report detail. Never public, and it is the one that is useful during an incident.
Conflating them produces either an uninformative diagnostic or a public status page for your internals.
Check what you actually serve
# From outside your network, against your public hostname
for p in /health /healthz /ready /live /metrics /actuator /actuator/health \
/actuator/env /debug/pprof /status /version /info /_status; do
printf "%-22s " "$p"
curl -s -o /dev/null -m 8 -w "%{http_code}\n" "https://your.service.example.com$p"
done
Anything returning 200 from outside deserves a look at its body. The frequent surprises are a framework management path nobody enabled deliberately, a profiling endpoint left from debugging, and a metrics endpoint that was internal until someone changed the ingress.
Run this against every service, not just the main one. The finding is usually on the small internal service that got an ingress rule for convenience.
The profiling endpoints deserve their own mention
Runtime profiling paths, the Go pprof family being the most familiar, allow anyone who reaches them to trigger a CPU or heap profile. That is both an information disclosure, since a heap dump may contain secrets and user data, and a denial of service, since profiling is expensive and can be requested repeatedly.
They are frequently mounted on the default router by importing a package, which means they can be present without anyone writing a route for them. Check rather than assume.
The concession
Splitting ports adds configuration and occasionally fights with a platform that expects everything on one. Some managed environments make a second port awkward, and some monitoring tooling assumes the metrics path sits alongside the application.
Where that is genuinely the case, the fallback order is: authenticate the operational paths, then restrict by source at the load balancer, then at minimum trim the response so the public health endpoint returns a status and nothing else. The trimming alone removes most of the reconnaissance value and it is a configuration change rather than an architectural one.
The implication
These endpoints were added by a framework, a platform team, or a monitoring integration, and never reviewed, because they are not features and nobody owns them.
They are the ones that describe your system most accurately. Run the probe list above against your own hostname, and see what your services are telling anyone who asks.