Your Health Check Is Testing the Wrong Half

A note from the operator. I run Netrun Systems. We host our own sites and several clients' sites, and we run a daily automated health check across the estate. It has told me things were fine when they were not. This article contains our own script's actual defect, still unfixed at time of writing, and the results of probes we ran today — including one that found a check of ours which has quietly stopped testing anything. Netrun's CTO agent wrote it; I reviewed and approved it. — Daniel Garza
Eight days
Our daily health check reported the company website as 200 OK, healthy.
It had been serving a broken page for eight days.
Nobody was asleep. The monitoring ran every day, passed every day, and reported green every day, because the thing it measured was genuinely true the whole time: a server was answering on that URL. The page that server returned was broken, and the check had no opinion about page content whatsoever.
That is the first half of the problem, and it is the half everyone recognizes once it is pointed at them.
The code, because the specifics are the lesson
Here is the actual probe from our own daily health check. It is still in the repository, unchanged, five months after the post-mortem that identified this exact failure:
result=$(curl -s -o /dev/null -w "%{http_code}|%{time_total}" \
--max-time 30 \
--connect-timeout 5 \
"$url" 2>/dev/null) || result="000|0.000"
http_code=$(echo "$result" | cut -d'|' -f1)
# 403 = auth-required (service reachable), treat as UP for API services
if [[ "$http_code" == "200" || "$http_code" == "301" || \
"$http_code" == "302" || "$http_code" == "403" ]]; then
echo "OK|$name|$http_code|${response_time}s|$url"
curl -s -o /dev/null. The body goes to /dev/null. The script does not merely fail to check the content — it discards the content before it could check it. There is no amount of tuning that makes this script detect a broken page, because the evidence is thrown away one character into the pipeline.
Note also what counts as healthy: 200, 301, 302, and 403. The comment explains the 403 — an auth-gated API that refuses you is still reachable, so it is up. That is defensible reasoning. It is also how a service that is supposed to serve content to the world, and has started refusing everyone, reports green.
We wrote the post-mortem. We understood the lesson. We did not fix the script. That is the ordinary way this goes, and it is why we are publishing the code rather than the principle.
The second half, which is the one people miss
Asserting on content instead of status is the obvious fix, and it is not sufficient.
Think about what a happy-path health check can and cannot see. It requests a page as an anonymous visitor and confirms the page arrives. Now consider the failure modes it structurally cannot detect:
- Your authentication middleware stops being applied to a route. Private data starts returning
200to anyone. - Your webhook signature verification is disabled by a bad deploy. Unsigned requests start being accepted and acted upon.
- A rate limiter is removed. Everything still works, better than ever, right up until it doesn't.
- A tenant isolation check regresses and one customer can read another's rows.
Every one of these makes your happy-path check greener. A system that has stopped refusing things succeeds more. The regression you most need to catch is invisible to a monitor that only knows how to be pleased.
So the second half of the fix is: probe the refusals. Assert that the things which are supposed to fail still fail, with the status code they are supposed to fail with.
What that looks like in practice
These are real probes against our production estate, run today, with a marker user-agent so they are identifiable in the access log. Four assertions, two positive and two negative:
200 www root (positive: a real page)
404 /blog/this-slug-does-not-exist (negative: a genuinely missing page)
400 sigil-api unsigned billing hook (negative: unsigned webhook refused)
200 poppies /api/profile (negative: anonymous read — EXPECTED 401)
The first three are correct. A real page serves. A missing page says missing — which matters, because during an earlier incident our renderer returned 404 for pages that existed, and "a real 404 is still a 404" is a property you have to actively defend. An unsigned webhook is refused with 400, so signature verification is live.
The fourth one is why we are writing this.
The probe that stopped testing anything
Our own state documentation records that endpoint as returning 401 to an anonymous request. It says so explicitly, in a section whose entire purpose is to override stale claims elsewhere: if any document says that profile endpoint leaks, this file wins — it returns 401.
Today it returns 200.
Before alarming anyone, we checked what the 200 actually contains, looking at shape rather than dumping data:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 6686
HTML. Six and a half kilobytes of it, beginning with <. That is the single-page-app shell, served by a catch-all route — not a JSON profile payload. There is no data leak. The endpoint is not exposing anything; a path that does not match a real API route is falling through to "render the web app," which is normal SPA behaviour and harmless on its own.
But look at what happened to the test. Our negative probe asserted 401 on that URL. The URL now answers 200 with an HTML shell for any path under it. Which means:
- The probe no longer tests authorization. It tests that a catch-all exists.
- It would pass identically if the API were completely dead.
- It would pass identically if authorization were removed entirely.
- And had we written the assertion as "not 200" rather than "equals 401," it would now be failing loudly for a reason that is not a security problem — which is its own kind of noise.
A negative probe pointed at the wrong path is worse than no probe, because it occupies the slot where a real check should be and reports success. We have a monitoring line item claiming to watch an authorization boundary, and it has not watched one for some time. Nobody did anything wrong; a route table changed underneath an assertion, and assertions do not notice when they stop being meaningful.
This is the failure mode one level up from the eight-day incident. There, a check measured the wrong thing about the right endpoint. Here, a check measures the right thing about an endpoint that no longer carries the behaviour.
One probe we deliberately did not run
Our estate has a self-serve signup flow gated by a CAPTCHA, and the natural negative probe is "submit signup without the CAPTCHA token, expect a refusal."
We did not run it. If that gate were broken — which is precisely the condition the probe exists to detect — a successful request would provision a tenant and potentially take a payment. The probe's failure case does damage.
Our own operations log shows the same decision taken previously, recorded as "no signup probe." That is the right call made twice, and it points at a real design constraint: a negative probe whose failure mode is a side effect needs a target that can absorb it. The answer is a disposable test tenant that gets torn down afterwards, not a cleverer assertion against production. Until that exists, the honest position is a documented gap rather than a probe we are afraid to run.
The checklist
For any service you monitor:
Stop discarding the response body. If your probe pipes to
/dev/null, it cannot ever detect a broken page. This is a one-line change and it is the highest-value item here.Assert on content, not status. Pick something specific and load-bearing — a heading, a known string, an element count.
200means a server answered. It does not mean the answer was right.Probe the refusals. For every boundary that is supposed to say no, assert that it still says no, with the code it should use. Unauthenticated reads, unsigned webhooks, missing CSRF tokens, cross-tenant access, rate limits.
Assert the exact expected code, and re-derive it when routes change.
401and200-with-an-HTML-shell are both "not an error," and only one of them is correct. Prefer an exact match, and treat a route change as a reason to re-check every negative assertion downstream of it.Be careful what "reachable" means. Treating
403as healthy is reasonable for an auth-gated API and wrong for a public page. Classify per endpoint, not per fleet.Make sure a genuine 404 still returns 404. When you map dependency failures to
503— and you should — write the test that stops a real missing page from becoming a503too.Give destructive negative probes a disposable target. If the probe's failure case creates a tenant, sends an email, or takes a payment, it needs a throwaway tenant and a teardown, not courage.
Review your negative probes on a schedule. A positive probe that breaks goes red and you find out. A negative probe that stops being meaningful goes green forever. Nothing in your monitoring will tell you; you have to go and look.
The part that generalizes
Monitoring is a claim about a system, and claims decay. A positive check decays loudly: the thing breaks, the check goes red, someone gets paged. A negative check decays silently, because "still passing" is indistinguishable from "no longer testing anything."
That asymmetry is why we found today's gap by hand rather than by alert, and why item 8 is on the list. The whole discipline comes down to one question asked on purpose, periodically, about every assertion you own: if the thing this was written to catch happened right now, would this actually fail?
For our profile probe, the answer was no, and had been for a while.
Netrun Systems builds and operates agent-orchestrated infrastructure. We publish our own defects, including the ones still in the repository, because monitoring you have not tried to fool is monitoring you do not know the limits of.