Article

A status endpoint that's coarse in public, detailed in private

How to expose a health endpoint that gives the public a coarse up/degraded roll-up while unlocking full per-service detail only to a server-to-server caller holding a shared secret. Covers constant-time comparison, private caching, fail-closed defaults, and a deploy-order-safe rollout.
July 14, 2026
Topics:ObservabilitySecurity
Tags:Next.jsDocker

A public status page is useful, and also a gift to attackers: per-service up/down, latency, internal hostnames and IPs are a live map of your infrastructure. We wanted the health signal without the reconnaissance. Here is the pattern.

The problem

Our headless front end shows a small status strip. It fetched a Drupal /api/system-status endpoint that returned per-service detail to everyone — Postgres, Redis, Solr, the mesh, monitoring, storage, down to an internal LAN IP. An anonymous caller got a complete service inventory and outage map.

The shape

Two audiences, one endpoint:

  • Anonymous gets a coarse roll-up: { status: "ok" | "degraded", time }. Enough to render a green or red dot. No service names, no latency, no addresses.
  • An authorized caller gets the full per-service breakdown.

The question is how the front end proves it is authorized without shipping a credential to the browser.

Server-to-server, not browser-to-server

The browser never sees the secret. The front end's own server renders the strip: it fetches Drupal from the server, attaches a shared secret in a header, and passes only the coarse result down to the client. The secret lives in the server's environment, never in a bundle.

// front-end server -> Drupal (secret stays server-side)
fetch(statusUrl, { headers: { "x-status-secret": process.env.STATUS_SECRET } })

Drupal unlocks the detailed response when the header matches — compared in constant time — or when the caller holds the admin permission. Everyone else gets the roll-up.

$detailed = $user->hasPermission('administer site configuration')
         || hash_equals($configured, $provided);

Details that matter

  • Constant-time compare. Use hash_equals() (or crypto.timingSafeEqual), never ==, so the secret cannot be recovered by timing.
  • Keep the detailed response private. Mark it no-store and vary on the secret header, so a shared cache never serves detail to an anonymous request.
  • Deploy-order-safe. Until the secret is present on both sides, the endpoint returns the coarse roll-up. A header Drupal does not yet accept, or a secret the front end does not yet send, both degrade to public-safe — so the backend, front-end, and infrastructure changes can ship in any order.
  • Fail closed. No secret configured means detail is off for everyone, not on.

The result

The public internet sees one honest bit — up or degraded. Operators and the front-end server see everything. The infrastructure map never leaves the trust boundary, and the health signal still works.