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
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
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
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
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 — the kind every large organisation runs at the edge of its network — 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.
Want a second set of eyes on your monitoring gaps?
Certificate chains are one blind spot. Log sources that never get watched are another — our log monitoring guide and security audit checklist cover the rest of the "green dashboard, broken service" problem.
View TemplatesQuestions? Find me on u/Arch0ne or Ionut-Robert Sandu.
Comments (0)
Loading comments...