DEV Community

Ionut-Robert Sandu
Ionut-Robert Sandu

Posted on

Your Certificate Monitoring Only Checks the Leaf. That Is Not the Same Thing as Your Chain Being Valid.

Here is a monitoring result. It is from a server built in a lab a few minutes earlier,
and every number in it is real.

$ echo | openssl s_client -connect app.lab.example:443 -servername app.lab.example \
  2>/dev/null | openssl x509 -noout -enddate -checkend 2592000

notAfter=Dec 11 14:12:02 2026 GMT
Certificate will not expire
Enter fullscreen mode Exit fullscreen mode

Ninety days of runway, comfortably past the thirty-day threshold. Green.

The site stops working in twenty days.

What the check missed

openssl s_client hands back the leaf certificate by default. That is the
certificate for the hostname, the one with your domain in it, the one everybody
means when they say "the certificate". It is also only the first entry in a list.
A browser does not trust a leaf because the leaf says a date. It trusts it
because it can build a path from that leaf to a root it already trusts, and
every certificate on that path has to be valid at the same moment.

Here is the same server, with every certificate it sends checked instead of only
the first one:

CN = app.lab.example               Dec 11 14:12:02 2026 GMT   ok
CN = Lab Intermediate CA (short)   Oct  2 14:12:02 2026 GMT   EXPIRES WITHIN 30d
Enter fullscreen mode Exit fullscreen mode

The leaf outlives the intermediate that signed it. On October 2nd, the chain
stops validating, and the leaf's own December expiry becomes irrelevant. Clients
will report an expired certificate. Your dashboard will report ninety days
remaining, because the thing your dashboard is looking at does, in fact, have
ninety days remaining.

Nothing here is exotic. A CA can perfectly well issue you a certificate that
outlives its own issuing intermediate, and the certificate is not malformed when
it does. The constraint lives in path validation at the client, not in the
issuance.

Build it yourself in two minutes

Do not take my word for the shape of this. The whole thing is four openssl
invocations.

# Root CA, long lived
openssl req -x509 -newkey rsa:2048 -nodes -keyout root.key -out root.crt \
  -days 3650 -subj "/CN=Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

# Intermediate CA that expires in 20 days
openssl req -newkey rsa:2048 -nodes -keyout inter.key -out inter.csr \
  -subj "/CN=Lab Intermediate CA (short)"
printf 'basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n' > inter.ext
openssl x509 -req -in inter.csr -CA root.crt -CAkey root.key -CAcreateserial \
  -out inter.crt -days 20 -extfile inter.ext

# Leaf valid for 90 days, signed by that intermediate
openssl req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr \
  -subj "/CN=app.lab.example"
printf 'basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:app.lab.example\n' > leaf.ext
openssl x509 -req -in leaf.csr -CA inter.crt -CAkey inter.key -CAcreateserial \
  -out leaf.crt -days 90 -extfile leaf.ext

cat leaf.crt inter.crt > fullchain.crt
Enter fullscreen mode Exit fullscreen mode

Serve fullchain.crt from anything, point your existing certificate monitor at
it, and watch it tell you everything is fine.

Checking the whole chain

The fix is not complicated, which is part of why the gap is annoying. Pull every
certificate the server actually sends, and run the expiry check against each one.

#!/bin/sh
# Usage: tls-chain-check.sh host:port [sni] [days]
HOST="$1"; SNI="${2:-${1%%:*}}"; DAYS="${3:-30}"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT

echo | openssl s_client -connect "$HOST" -servername "$SNI" -showcerts 2>/dev/null \
| awk -v d="$TMP" '
    /-----BEGIN CERTIFICATE-----/ { n++; p=1 }
    p                             { print >> (d "/c" n ".pem") }
    /-----END CERTIFICATE-----/   { p=0 }'

[ -f "$TMP/c1.pem" ] || { echo "no certificates received from $HOST"; exit 2; }

rc=0
for f in "$TMP"/c*.pem; do
    subj=$(openssl x509 -in "$f" -noout -subject | sed 's/^subject=[ ]*//')
    end=$(openssl x509 -in "$f" -noout -enddate | sed 's/^notAfter=//')
    if openssl x509 -in "$f" -noout -checkend $((DAYS * 86400)) >/dev/null 2>&1; then
        state="ok"
    else
        state="EXPIRES WITHIN ${DAYS}d"; rc=1
    fi
    printf '%-34s %-26s %s\n' "$subj" "$end" "$state"
done
exit $rc
Enter fullscreen mode Exit fullscreen mode

Non-zero exit when anything on the path is inside the window, which is what you
want for a cron job or a CI gate.

Two details in there are deliberate and worth stealing.

-showcerts before anything else. Without it you get the leaf and nothing
else, which is how you ended up here.

-servername separate from the connect address. On a host serving multiple
sites, the certificate you get depends on the SNI you send, not on the IP you
connected to. If you monitor by IP and omit SNI, you are checking whatever the
default virtual host happens to present, which may be a completely different
certificate from the one your users receive. This is a good way to monitor
something real and useless for eighteen months.

Three more places the same mistake hides

The chain the server sends is not always the chain the client builds.
A client with a cached intermediate, or one that follows the AIA extension to
fetch a missing issuer, can successfully validate a chain your server sent
incompletely. Your desktop browser says fine; a stripped-down container with no
cached intermediates and no outbound access to the CA's AIA endpoint says
handshake failure. Checking from a machine with a rich trust state hides the
problem from you specifically.

Your checker's trust store is not your users' trust store. If the checker
runs openssl verify against the OS bundle on a long-lived VM, you are
validating against whatever roots that VM had at its last update. Mobile clients,
older Java runtimes, and embedded devices all carry different sets. "Valid" is
not a property of a certificate. It is a relationship between a certificate and
a particular trust store at a particular time.

The interception layer has its own copy. If traffic crosses a proxy that
terminates and re-signs TLS,
there are now two certificates on the path to your user, issued by two different
authorities, renewed by two different processes.
Probe a public site from inside an environment with intercepted egress and every
certificate comes back signed by the gateway's own CA — the leaf you would be
"monitoring" was manufactured about a second earlier. If you run monitoring from
inside a corporate network and alert on the certificate you receive, you may be
monitoring the health of your own proxy.

What to actually alert on

Alert on the minimum remaining lifetime across the full path, not on the
leaf. It is one number, it is the one that determines when things break, and it
is strictly more conservative than what most tools give you today.

Then make the thresholds reflect who fixes what. A leaf inside thirty days is
your problem and your automation should have handled it. An intermediate inside
sixty days is usually your CA's problem, and the remediation is to re-issue and
redeploy — a different task, on a different timeline, often owned by a different
team. Folding both into a single "certificate expiring" alert guarantees that at
least one of them gets handled by someone who cannot fix it.

This matters more every year, not less. Public certificate lifetimes are already
down from 398 days to 200, drop to 100 in March 2027, and reach 47 in March 2029.
Renewal stops being a calendar event and becomes a pipeline. Pipelines fail
quietly, which means the monitoring has to be right about what it is looking at.

Checking the leaf was a reasonable approximation when certificates lasted a year.
It is not one anymore.

Top comments (0)