Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Will Your TLS Certificate Renew—and Reach Your Visitors?

Subtitle: Six checks—expiry and renewal margin, name and chain, every hostname you serve, protocol versions, renewal that reaches the running server, and an outside monitor. A browser security warning can stop customers

Subtitle: Six checks—expiry and renewal margin, name and chain, every hostname you serve, protocol versions, renewal that reaches the running server, and an outside monitor.

A browser security warning can stop customers from reaching your site. The checks below are for anyone who keeps a site online—whether you run the servers or own the business.

Published by the HackerMe team (Agura). The checks are vendor-neutral. Each one is labeled **safe on any public site* (it makes an ordinary HTTPS connection, the same as any visitor) or maintainer check (it needs access to the server or the certificate tooling).*

You've configured automatic certificate renewal. How do you know it worked?

It matters more each year. Let's Encrypt stopped sending expiration emails on June 4, 2025, so its users no longer get a reminder when renewal silently fails. And the CA/Browser Forum's Ballot SC-081 reduces the maximum lifetime of public TLS certificates from 398 days to 47 days, in steps from March 2026 to March 2029. Renewal has to be checked, not assumed.

Replace example.com with a domain you own or have explicit permission to test. Commands shown use OpenSSL 3.x syntax.

1. Expiry date and renewal margin (safe on any public site)

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -enddate

-servername sends SNI, so a shared server returns the right certificate.

To check remaining lifetime without relying on date (GNU and BSD differ), use -checkend. The threshold below is an example—set THRESHOLD_DAYS to match your renewal window:

THRESHOLD_DAYS=21   # example; adjust to your renewal window
SECONDS_LEFT=$((THRESHOLD_DAYS * 86400))
if openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend "$SECONDS_LEFT"; then
  echo "More than ${THRESHOLD_DAYS} days left"
else
  echo "Expires within ${THRESHOLD_DAYS} days (or no certificate)"
fi

-checkend exits 0 when the certificate will not expire within the given number of seconds, and non-zero otherwise.

  • Expected: a notAfter date, and a comfortable margin for your chosen threshold.
  • Finding: an expiry inside your renewal window.
  • Doesn't prove: that renewal is configured. A valid date today says nothing about tomorrow.

2. Name match and chain (safe on any public site)

openssl s_client -connect example.com:443 -servername example.com \
  -verify_hostname example.com </dev/null 2>&1 | grep 'Verify return code'
  • Expected: Verify return code: 0 (ok).
  • Finding: 62 (hostname mismatch), 10 (certificate has expired), or 21 (unable to verify the first certificate). Code 21 usually means the server does not send its intermediate certificate. Some browsers fill the gap, so the site can look fine in a browser and still fail for API clients.
  • Doesn't prove: trust everywhere. It reflects your machine's trust store.

3. Every name you serve (safe on any public site, plus maintainer check)

Certificate Transparency logs record publicly trusted certificates as they are issued (MDN: Certificate Transparency). Searching them finds forgotten hostnames:

curl -fsS 'https://crt.sh/?q=%25.example.com&output=json' | jq -r '.[].name_value' | sort -u

Run checks 1 and 2 against each name that still resolves.

  • Finding: a live hostname nobody owns, or one served from a separate load balancer with a manually uploaded certificate.
  • Doesn't prove: a complete inventory. Private certificates are not logged, and crt.sh is a community service that can be slow or return unrelated names.

4. Protocol versions (safe on any public site)

RFC 8996 deprecates TLS 1.0 and 1.1, and the OWASP TLS Cheat Sheet says to prefer TLS 1.3, allow TLS 1.2 for compatibility, and disable the older versions.

for v in tls1 tls1_1 tls1_2 tls1_3; do
  printf '%s: ' "$v"
  openssl s_client -connect example.com:443 -servername example.com -"$v" \
    -cipher 'DEFAULT:@SECLEVEL=0' </dev/null 2>/dev/null | grep -m1 '^New,'
done
  • Expected: a cipher for tls1_2 and tls1_3. For tls1 and tls1_1, (NONE) is consistent with the server rejecting those versions—but (NONE) alone does not prove the server blocked them; your local OpenSSL build may refuse the old protocol even when the server would accept it.
  • Careful: recent OpenSSL builds refuse old protocols themselves. @SECLEVEL=0 relaxes that, but confirm a surprising result with a second tool.
  • Doesn't prove: a good cipher configuration. For that, compare your server against a maintained template such as the Mozilla SSL Configuration Generator.

5. Renewal reaches the running server (maintainer check)

A successful renewal on disk is not the same as visitors receiving the new certificate. Separate three steps. With Certbot (testing flags):

(a) Test renewal against the staging server (no certificates saved to disk):

certbot renew --dry-run

By default, --dry-run runs pre/post hooks but does not run deploy hooks.

(b) Test that the deploy hook runs. Add --run-deploy-hooks so a dry run also executes deploy hooks (Certbot --run-deploy-hooks). The hooks use the current active certificate, not the temporary test certificate from the dry run—so this confirms the hook fires (for example, reloads the web server), not that a brand-new cert was loaded:

certbot renew --dry-run --run-deploy-hooks

(c) Verify the certificate actually served. After a real renewal (or after you expect the new file to be live), compare the on-disk certificate with what the TLS terminator presents:

openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -serial -enddate
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -serial -enddate
  • Expected: the same serial in both when you are checking the component that terminates TLS and reads that file (for example, the origin web server).
  • Caveat: behind a CDN or load balancer, the on-disk origin certificate and the public certificate can differ intentionally. Compare only against the device that should be serving the file you are reading; do not treat a mismatch through a CDN edge as proof of a failed reload.
  • Finding (direct terminator): a newer certificate on disk than on the wire—renewal worked, delivery did not.

6. Monitor from outside (maintainer check to set up)

Put your hostnames in hosts.txt, one per line, and run this daily from a machine that is not the web server. -verify_return_error makes OpenSSL abort with a non-zero exit when verification fails (OpenSSL s_client). The 21-day window is an example—adjust THRESHOLD_DAYS to your renewal policy:

#!/bin/sh
THRESHOLD_DAYS=21   # example; adjust to your renewal window
while read -r h; do
  [ -n "$h" ] || continue
  out=$(timeout 15 openssl s_client -connect "$h:443" -servername "$h" \
        -verify_hostname "$h" -verify_return_error </dev/null 2>/dev/null)
  rc=$?
  if [ "$rc" -ne 0 ]; then
    echo "ALERT $h: no connection or verification failed (exit $rc)"
  elif ! printf '%s\n' "$out" | openssl x509 -noout -checkend $((THRESHOLD_DAYS * 86400)) >/dev/null 2>&1; then
    echo "ALERT $h: no certificate, or it expires within ${THRESHOLD_DAYS} days"
  fi
done < hosts.txt

-checkend fails if the certificate expires within the given number of seconds. Send every ALERT line to a person, not just a log.

Illustration: the certificate that renewed but never arrived

This is a constructed example to show the process, not a check run on any customer site.

A team runs Certbot on a schedule, and the renewal log shows success. The outside monitor (check 6) still reports that www.example.com expires within their chosen window (21 days in this example):

  1. Check 1 confirms the served certificate has nine days left.
  2. Check 5 shows a newer serial on disk than on the wire (compared at the TLS terminator). Renewal worked, delivery did not.
  3. The cause: the web server reads the certificate only at start, and nothing reloaded it after renewal. certbot renew --dry-run alone would not have caught that—deploy hooks do not run on a dry run unless you add --run-deploy-hooks.
  4. The fix: a deploy hook that reloads the server, then check 5 again until both serials match at the terminator.
  5. Check 3 also finds api.example.com on a separate load balancer with a manually uploaded certificate. It gets an owner and a date.

Without the outside check, the first warning would have been a browser error.

Make it routine

Run checks 1 and 2 after every deployment and certificate change, check 3 monthly, and check 6 daily. A clean result means the certificates you tested were valid when you tested them. It does not mean the site is secure.

Disclosure: we are the team behind HackerMe, a free automated website security scanner: https://hackerme.ai

The checks above work without it. What do you monitor that we missed? We would like to hear it in the comments.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.