Admin, Internal and Debug Endpoints
The highest-privilege surface in your system, and usually the least defended, because it was 'internal'.
Admin and internal endpoints hold the most power and receive the least scrutiny. They are built quickly, for colleagues, under an assumption — usually "nobody outside can reach this" — that stops being true the first time infrastructure changes.
Network position is not a credential
"It is only on the internal network" fails to: an SSRF that makes your own server issue the request, a compromised pod in the same namespace, a VPN with too many members, a misconfigured ingress, a contractor's laptop, and a cloud load balancer someone created for testing. Internal endpoints need authentication exactly as much as public ones.
Controls worth the effort
- Separate authentication, not just a role flag — ideally a distinct token audience or mTLS.
- Mandatory MFA for human admin access.
- Re-authentication (sudo mode) for destructive actions.
- Audit everything, with who, what, why and a ticket reference.
- Alert on use, not just on failure. Admin actions should be rare enough to be interesting.
- Network restriction as a second layer — valuable, and never the first one.
Debug endpoints
Health checks, metrics, profilers, heap dumps, GraphQL playgrounds, Swagger UI with a live "try it" button. Each is useful; each is reconnaissance or worse. A heap dump contains credentials and customer data in plaintext. /debug/pprof exposed publicly has caused real incidents.
Rule: debug surfaces are off in production, or they are authenticated. "Off by default in the config" is not the same as off — check what production actually serves.
Health checks specifically
A health endpoint should return {"status":"ok"}. The verbose version — dependency versions, database hostnames, queue depths, environment variables, git SHA — is a free map for an attacker. Keep the detailed one on a separate authenticated path.
Example
# Things that should not answer on your production hostname
for path in \
/debug/pprof/ /debug/vars /actuator /actuator/env /actuator/heapdump \
/metrics /graphql /graphiql /swagger-ui /api-docs /__debug__ \
/.env /.git/config /server-status /admin /internal/health
do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "https://dfg.com$path")
[ "$code" = "200" ] && echo "EXPOSED: $path"
done
# EXPOSED: /metrics ← queue depths, customer counts, internal hostnames
# EXPOSED: /actuator/env ← environment variables. Including secrets.
# EXPOSED: /graphql ← introspection enabled? full schema, free.
# Run this against production. Not staging. Production.When to use it
- An exposed actuator endpoint leaks environment variables including a database password, found by a routine external scan.
- An internal-only admin API is reached through an SSRF in an unrelated image-import feature, because it trusted network position.
- A verbose health check reveals internal hostnames and dependency versions, giving an attacker a version-specific exploit to try.
More examples
Admin routes with layered controls
Returning 404 rather than 403 for an unlisted IP means a scan cannot even confirm that an admin surface exists on this host.
const adminRouter = express.Router();
// Layer 1 — network restriction. A second layer, never the first.
adminRouter.use((req, res, next) => {
if (!ADMIN_CIDRS.some((cidr) => ipInCidr(req.ip, cidr))) {
// Log it: a hit here is either a misconfiguration or reconnaissance.
logger.warn({ ip: req.ip, path: req.path }, 'admin access from unlisted ip');
return res.status(404).json({ error: 'not_found' }); // 404, not 403
}
next();
});
// Layer 2 — a SEPARATE credential, not the same token with a role flag.
adminRouter.use(async (req, res, next) => {
const claims = await verifyToken(readBearer(req), {
audience: 'https://dfg.com/admin', // ← a normal API token fails here
issuer: ISSUER,
}).catch(() => null);
if (!claims) return res.status(401).json({ error: 'authentication_required' });
req.user = { id: claims.sub, role: claims.role, mfa: claims.amr?.includes('otp') };
next();
});
// Layer 3 — MFA is mandatory here, whatever the account's own preference.
adminRouter.use((req, res, next) =>
req.user.mfa ? next() : res.status(401).json({ error: 'mfa_required' }));
// Layer 4 — role.
adminRouter.use(requireRole('admin'));
// Layer 5 — audit every request, before and after.
adminRouter.use(async (req, res, next) => {
const started = Date.now();
res.on('finish', () => audit.record({
event: 'admin.request',
actor: req.user.id, method: req.method, path: req.path,
status: res.statusCode, durationMs: Date.now() - started,
ip: req.ip, ticket: req.get('x-ticket-ref') ?? null,
}));
next();
});
// Layer 6 — destructive actions additionally require fresh re-authentication.
adminRouter.delete('/users/:id', requireSudo({ maxAgeS: 900 }), deleteUser);
adminRouter.post('/refunds', requireSudo({ maxAgeS: 900 }), issueRefund);
app.use('/api/admin', adminRouter);
// Six layers sounds heavy. It is roughly forty lines, applied once, protecting
// the functions that can destroy the business.Health and debug surfaces, done safely
Binding metrics to loopback on a separate port removes the question entirely — there is no route from the internet to argue about.
// ── Public health: minimal, cheap, no dependencies touched ────────────
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Nothing else. Not the version, not the hostname, not the git SHA.
// ── Readiness: for the orchestrator, still terse ──────────────────────
app.get('/ready', async (req, res) => {
const ok = await db.raw('SELECT 1').then(() => true).catch(() => false);
res.status(ok ? 200 : 503).json({ status: ok ? 'ready' : 'not_ready' });
// Note: no error detail. "connection refused to db-prod-1.internal:5432"
// tells an attacker your topology.
});
// ── Detailed health: authenticated, separate path ─────────────────────
app.get('/internal/health/detail', adminAuth, async (req, res) => {
res.json({
version: process.env.GIT_SHA,
dependencies: await checkAll(),
queueDepths: await queueStats(),
uptime: process.uptime(),
});
});
// ── Metrics: never public ─────────────────────────────────────────────
// Prometheus metrics leak business data: customer counts, order volumes,
// error rates by endpoint, internal service names.
app.get('/metrics', metricsAuth, promClient.register.metrics);
// Better: bind the metrics server to a separate port that is not routed
// externally at all.
const metricsApp = express();
metricsApp.get('/metrics', promClient.register.metrics);
metricsApp.listen(9090, '127.0.0.1'); // loopback only
// ── Debug surfaces: assert they are OFF, do not assume ────────────────
if (process.env.NODE_ENV === 'production') {
for (const [name, enabled] of Object.entries({
GRAPHQL_PLAYGROUND: process.env.GRAPHQL_PLAYGROUND === 'true',
GRAPHQL_INTROSPECTION: process.env.GRAPHQL_INTROSPECTION === 'true',
SWAGGER_UI: process.env.SWAGGER_UI === 'true',
DEBUG_ROUTES: process.env.DEBUG_ROUTES === 'true',
STACK_TRACES: process.env.SHOW_STACK_TRACES === 'true',
})) {
if (enabled) throw new Error(`${name} must not be enabled in production`);
}
}
// Refusing to BOOT is better than a warning nobody reads.The external scan, as a scheduled job
Running it from outside the network is essential — the same script run from inside the VPC passes on hosts that are wide open to the internet.
#!/usr/bin/env bash
# scripts/external-exposure-check.sh
# Run WEEKLY from OUTSIDE your network, against PRODUCTION. Fail the job on any hit.
set -uo pipefail
HOST="${1:-https://dfg.com}"
FAIL=0
SHOULD_NOT_EXIST=(
/debug/pprof/ /debug/vars /debug/routes
/actuator /actuator/env /actuator/heapdump /actuator/threaddump
/metrics /prometheus
/graphiql /playground /altair
/swagger-ui /swagger-ui.html /api-docs /openapi.json
/.env /.git/config /.git/HEAD /config.json /package.json
/server-status /server-info /phpinfo.php
/admin /administrator /wp-admin
/api/internal /internal /ops /_status
)
for path in "${SHOULD_NOT_EXIST[@]}"; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HOST$path")
if [ "$code" = "200" ]; then
echo "EXPOSED $path ($code)"
FAIL=1
fi
done
# GraphQL introspection specifically — a 200 on /graphql is not enough to judge
intro=$(curl -s --max-time 5 -X POST "$HOST/graphql" \
-H 'Content-Type: application/json' \
-d '{"query":"{__schema{types{name}}}"}')
if echo "$intro" | grep -q '__schema'; then
echo "EXPOSED GraphQL introspection is enabled"
FAIL=1
fi
# And the health endpoint's verbosity
body=$(curl -s --max-time 5 "$HOST/health")
if echo "$body" | grep -qiE 'version|host|env|database|internal|sha'; then
echo "VERBOSE /health leaks detail: $body"
FAIL=1
fi
exit $FAIL
# Scheduled, from outside, against production. Every one of these has been a
# real incident at a real company, usually introduced by a config change months
# after the code was reviewed.
Discussion