How to Check SSL Certificate Expiry and TLS Version (2026)

Check SSL certificate expiry and TLS version in a browser, with openssl or a script. Data from 215 sites: 32.6% still accept TLS 1.0 and lifetimes shrink.

BugViso

16 min read

To check SSL certificate expiry, open the padlock in your browser and read the "Expires on" date, or run openssl s_client -connect example.com:443 -servername example.com </dev/null | openssl x509 -noout -enddate. Check the TLS version the same way: openssl s_client -tls1_1 should fail on a well-configured server, because TLS 1.0 and 1.1 have been deprecated since RFC 8996 in 2021.

That's the five-second answer. The harder question is why certificates still expire on sites that "have auto-renew", and what the shrinking certificate lifetimes of 2026 to 2029 mean for you. On 9 October 2026 we inspected the certificates and TLS configuration of 219 randomly sampled homepages. Two certificates had fewer than 10 days left, and both were long-lived commercial certificates, the kind people renew by hand. 32.6% of servers still accepted a TLS 1.0 or 1.1 handshake.

This guide covers three ways to check, what our data shows, the five reasons renewals fail, how to switch off legacy TLS, and a standard-library Python script you can run against any host.


Three Ways to Check SSL Certificate Expiry

Each method answers a slightly different question, so it helps to know which one you're running.

1. In the browser (one site, right now)

In Chrome or Edge, click the site-information icon left of the address bar, then Connection is secure → Certificate is valid. The Validity Period section shows "Issued On" and "Expires On". Firefox shows the same under Connection secure → More information → View Certificate.

The browser shows the certificate it received. If your site sits behind a CDN, that is the CDN's edge certificate, not the one on your origin server. Both can expire, and only one of them is visible from the browser.

2. With openssl (any host, scriptable)

bash
# Expiry date of the certificate served for this hostname
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate -issuer -subject
# notAfter=Dec 25 23:59:59 2026 GMT
# issuer=C = US, O = SSL Corporation, CN = ...
# subject=CN = example.com

# Exit code 1 if the certificate expires within 21 days (21 * 86400 seconds)
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend $((21*86400)) && echo "OK" || echo "RENEW NOW"

Always pass -servername. Without it, no SNI is sent, and a server hosting many sites can hand back a default certificate that belongs to a different domain. That gives you a false expiry date.

To check an origin server behind a CDN, connect to its IP while still sending your hostname:

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

3. With a full site audit (certificate plus everything around it)

An audit tool does the same TLS handshake, but it also checks whether HTTP redirects to HTTPS, whether the certificate's names cover the host, and whether the protocol is outdated, all on the same report as everything else. We cover what BugViso checks further down.


What 215 Live Certificates Showed

We took the same sample used in our website accessibility statistics study: 420 domains drawn at random (fixed seed) from Tranco list 94GG2, ranks 1,001 to 50,000, which left 219 reachable homepages. On 9 October 2026 we ran BugViso's own certificate inspector against each final hostname and probed legacy protocols separately. 215 completed a verified handshake. The other four failed: two timed out, one was reset, and one used a Diffie-Hellman key too small for OpenSSL 3 to accept.

Days until expiryCertificatesShare
0–14 days20.9%
15–30 days41.9%
31–60 days7133.0%
61–90 days8840.9%
91–200 days5023.3%
More than 200 days00.0%

The median certificate had 71 days left. No certificate in the sample had more than 200 days left.

Certificate lifetime (notBefore → notAfter)CertificatesShare
90 days or less14467.0%
101–200 days4420.5%
201–398 days2712.6%

Three findings stand out.

  1. The 200-day cap is already visible. The CA/Browser Forum adopted ballot SC-081, which phases the maximum public TLS certificate lifetime down from 398 days to 47 days between March 2026 and March 2029. Of the 188 certificates issued since 15 March 2026, the longest lifetime was 199 days. Every certificate over 200 days was issued before that date.
  2. The two certificates closest to expiry (6 and 9 days left) were both ~199-day commercial certificates, not 90-day automated ones. A 200-day certificate is renewed rarely enough that the process is often a calendar reminder, and reminders get missed.
  3. Issuers are concentrated. Google Trust Services (34.0%) and Let's Encrypt (32.6%) issued two-thirds of the certificates. Both are ACME-automated CAs, so for most sites "expiry" now means "an automation stopped working", not "someone forgot to pay".

💡 Rule of thumb: with 90-day certificates, a healthy renewal job replaces the certificate when about 30 days remain. A 90-day certificate with fewer than 25 days left almost always means renewal has been failing silently for a week or more.


TLS Version: What "Negotiated" Hides

A modern browser offers TLS 1.3, and a modern server picks it. In our sample, 90.2% of handshakes negotiated TLS 1.3 and 9.8% negotiated TLS 1.2. None negotiated anything older.

That doesn't mean old versions are off. When we offered only TLS 1.0 or only TLS 1.1, many servers still completed the handshake:

Probe (client offers only…)Servers that acceptedShare of 215
TLS 1.06831.6%
TLS 1.17032.6%
Either legacy version7032.6%

Most of these were CDN-fronted. 42 of the 70 legacy-accepting hosts returned server: cloudflare, and 42 of the 86 Cloudflare-fronted homepages in the sample accepted TLS 1.0. On a CDN this is a single dashboard setting. Cloudflare documents it as Minimum TLS Version.

Why it matters: RFC 8996 formally deprecated TLS 1.0 and 1.1, and current browsers refuse them. Leaving them on doesn't help real visitors. It only keeps a weaker protocol available, and it fails PCI DSS scans and most security questionnaires.

How to test which versions a server accepts

bash
for v in tls1 tls1_1 tls1_2 tls1_3; do
  if openssl s_client -connect example.com:443 -servername example.com -$v </dev/null >/dev/null 2>&1; then
    echo "$v: ACCEPTED"; else echo "$v: refused"; fi
done

On OpenSSL 3, TLS 1.0 and 1.1 are disabled at the default security level, so the tls1 probe can say "refused" even when the server would accept. Add -cipher 'DEFAULT:@SECLEVEL=0' to the 1.0 and 1.1 lines so your client can still offer them. The script below does this for you.

How to switch legacy TLS off

nginx
# ❌ Accepts deprecated protocols
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;

# ✅ Modern: TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
apache
# Apache httpd (mod_ssl)
SSLProtocol -all +TLSv1.2 +TLSv1.3

Caddy has used TLS 1.2 as its minimum by default for years. For copy-paste cipher suites by server type, Mozilla's SSL Configuration Generator is the standard reference. Choose the Intermediate profile unless you know every client supports TLS 1.3.


Why Auto-Renew Still Fails: 5 Root Causes

"We use Let's Encrypt, it renews itself" is true until something in the chain changes. These are the failure modes we see most often, with the check for each.

1. The HTTP-01 challenge can't reach the server

ACME's HTTP-01 challenge fetches http://example.com/.well-known/acme-challenge/<token> over port 80. A firewall change that closes port 80, an HTTP→HTTPS redirect to a host without the token, or a CDN rule that caches the 404 will all break renewal while the current certificate keeps working.

bash
# Should return 404 from YOUR server (not a CDN error page or a timeout)
curl -sI http://example.com/.well-known/acme-challenge/test | head -1

2. DNS moved, renewal didn't

The domain moved to a new host or CDN, but the old server is still running certbot for a name that no longer points at it. Renewal fails because validation reaches the new host. The new host often serves its own certificate, so nothing breaks until the new host's setup lapses. Keep one renewal owner per hostname.

3. A name was added but not covered

A new subdomain, or the www twin, starts receiving traffic, but the certificate's Subject Alternative Names (SANs) were never extended. Our sample showed this on the twin host: of 203 sites where both www and the apex resolved, 7 (3.4%) served a broken certificate on the twin. Three were hostname mismatches, two were incomplete chains ("unable to get local issuer certificate"), one failed the handshake and one timed out. Any link, ad or old bookmark pointing at the twin hits a full-page browser warning.

4. Two certificates, one forgotten

Behind a CDN there are two certificates: the edge certificate visitors see and the origin certificate the CDN uses to reach you. CDNs renew the edge automatically. A manually installed origin certificate expires quietly and turns into 5xx errors at the edge, because the CDN refuses the expired origin in strict mode.

5. Reminder emails went away

Many teams relied on CA expiry emails as their safety net. Let's Encrypt ended its expiration notification emails on 4 June 2025. Any monitoring that was only "we'll get an email" disappeared that day.

⚠️ Shrinking lifetimes make this urgent. Let's Encrypt has published its own schedule: its default profile moves to 64-day certificates on 10 February 2027 and to 45-day certificates on 16 February 2028. Under SC-081, every public CA must be at 47 days by March 2029. Manual renewal stops being viable. Every certificate needs automation and an independent check that the automation worked.


A Script That Checks Expiry, Lifetime, Legacy TLS and the Twin Host

This is the tool we used for the legacy-TLS and twin-host probes, cut down for daily use. It uses only the Python standard library, exits non-zero on any problem so you can run it from cron or a CI job, and warns at 21 days by default.

python
#!/usr/bin/env python3
"""check_tls.py: certificate expiry, lifetime, TLS versions and www/apex coverage.

Usage:
    python3 check_tls.py example.com [shop.example.com ...]
    python3 check_tls.py --warn 21 example.com      # exit 1 if any cert has <21 days left

Standard library only (Python 3.10+).
"""
import argparse, socket, ssl, sys, warnings
from datetime import datetime, timezone

warnings.filterwarnings("ignore", category=DeprecationWarning)
FMT = "%b %d %H:%M:%S %Y %Z"


def fetch(host, port=443, timeout=8):
    ctx = ssl.create_default_context()
    with socket.create_connection((host, port), timeout=timeout) as raw:
        with ctx.wrap_socket(raw, server_hostname=host) as s:
            return s.getpeercert(), s.version()


def accepts_only(host, version, port=443, timeout=8):
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE
    ctx.set_ciphers("ALL:@SECLEVEL=0")          # let OpenSSL 3 offer legacy suites
    ctx.minimum_version = ctx.maximum_version = getattr(ssl.TLSVersion, version)
    try:
        with socket.create_connection((host, port), timeout=timeout) as raw:
            with ctx.wrap_socket(raw, server_hostname=host) as s:
                return s.version() in ("TLSv1", "TLSv1.1")
    except (ssl.SSLError, ConnectionResetError, ConnectionAbortedError):
        return False
    except OSError:
        return None                                 # network problem, unknown


def check(host, warn_days):
    try:
        cert, proto = fetch(host)
    except ssl.SSLCertVerificationError as e:
        print(f"✗ {host}: certificate invalid: {e.verify_message}")
        return False
    except OSError as e:
        print(f"✗ {host}: cannot connect: {e}")
        return False
    nb = datetime.strptime(cert["notBefore"], FMT).replace(tzinfo=timezone.utc)
    na = datetime.strptime(cert["notAfter"], FMT).replace(tzinfo=timezone.utc)
    left = (na - datetime.now(timezone.utc)).days
    issuer = dict(x[0] for x in cert["issuer"]).get("organizationName", "?")
    sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
    ok = left >= warn_days
    notes = [f"    expires   {na:%Y-%m-%d}  ({left} days left, lifetime {(na - nb).days} days, issuer {issuer})",
             f"    protocol  {proto} negotiated"]
    legacy = [n for v, n in (("TLSv1", "1.0"), ("TLSv1_1", "1.1")) if accepts_only(host, v)]
    if legacy:
        ok = False
        notes.append(f"    ✗ still accepts TLS {', '.join(legacy)} (RFC 8996 deprecated both)")
    twin = host[4:] if host.startswith("www.") else "www." + host
    try:
        socket.getaddrinfo(twin, 443)
        try:
            fetch(twin)
            notes.append(f"    twin      {twin} has a valid certificate")
        except ssl.SSLCertVerificationError as e:
            ok = False
            notes.append(f"    ✗ twin    {twin} resolves but its certificate fails: {e.verify_message}")
        except OSError as e:
            notes.append(f"    ? twin    {twin} resolves but HTTPS failed: {e}")
    except OSError:
        notes.append(f"    twin      {twin} does not resolve (fine if you never link to it)")
    notes.append(f"    SANs      {', '.join(sans[:6])}{' …' if len(sans) > 6 else ''}")
    print(f"{'✓' if ok else '✗'} {host}" + ("" if left >= warn_days else f"  (fewer than {warn_days} days left)"))
    print("\n".join(notes))
    return ok


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("hosts", nargs="+")
    ap.add_argument("--warn", type=int, default=21, help="fail when fewer days than this remain (default 21)")
    a = ap.parse_args()
    results = [check(h.removeprefix("https://").split("/")[0], a.warn) for h in a.hosts]
    sys.exit(0 if all(results) else 1)

Real output against two hosts on 9 October 2026:

text
$ python3 check_tls.py example.com expired.badssl.com
✗ example.com
    expires   2026-12-25  (77 days left, lifetime 90 days, issuer SSL Corporation)
    protocol  TLSv1.3 negotiated
    ✗ still accepts TLS 1.0, 1.1 (RFC 8996 deprecated both)
    twin      www.example.com has a valid certificate
    SANs      example.com, *.example.com
✗ expired.badssl.com: certificate invalid: certificate has expired

Run it daily from cron with your own alert channel:

bash
# /etc/cron.d/tls-check: email the output when anything fails
15 7 * * * monitor python3 /opt/check_tls.py example.com www.example.com shop.example.com \
  || mail -s "TLS check failed" ops@example.com < /dev/null

How BugViso Checks Certificates and TLS

Every BugViso audit runs a live TLS inspection against the scanned host, using the same standard-library handshake as the script above. The Security section of the report shows:

  • Issuer, valid-from and valid-until dates, and days remaining. The certificate is flagged as expiring soon at 14 days or fewer, and as invalid when the handshake or chain verification fails, with the verifier's message (expired, hostname mismatch, missing intermediate).
  • The negotiated protocol and cipher, including whether the suite has forward secrecy. A negotiated TLS 1.0 or 1.1 is flagged as outdated.
  • SAN coverage: whether the certificate's Subject Alternative Names actually match the host you scanned.
  • HTTPS enforcement: whether http:// redirects to https://, and with which status code (301, 302, 307 or 308).

These feed the health score as deductions: −10 for an invalid or expired certificate, −6 for a SAN mismatch, −5 for an outdated negotiated protocol and −4 for expiring soon. They also appear in the PDF report's security checklist next to the fix.

There is one honest limit, and it's the "negotiated" problem described above. BugViso reports the version a modern client gets. It doesn't probe whether the server also accepts TLS 1.0 for old clients, so use the script for that check. To re-check certificates over time, set up a daily, weekly or monthly scheduled scan for the site. Each run re-inspects the certificate and lands in your audit records, so a renewal that has stopped working shows up as falling days-remaining well before the 14-day flag.

You can run a free scan on your domain to see the certificate panel alongside your header grade. For HTTP headers, see our guide to security headers like CSP, HSTS and X-Frame-Options. The security and privacy checks page lists everything that runs.


Traps and Edge Cases

  • Wildcards cover one label only. *.example.com matches shop.example.com but not example.com and not eu.shop.example.com (RFC 6125). Many certificate mismatches on the apex come from a wildcard-only certificate.
  • Incomplete chains work in your browser and fail elsewhere. Browsers cache and fetch intermediates, but curl, Python, payment webhooks and Googlebot's fetchers often don't. Two of the seven twin-host failures in our sample were exactly this. Test with openssl s_client -showcerts and confirm the intermediate is sent.
  • HSTS turns expiry into a hard outage. With Strict-Transport-Security, browsers remove the "proceed anyway" option, so an expired certificate becomes an outage with no way through. Set up HSTS only together with expiry monitoring.
  • Staging and mail hosts expire too. mail., api. and status. subdomains usually have separate certificates with separate renewal jobs. Add them to the script's host list.
  • Clock skew breaks validity checks. A server or client whose clock is wrong rejects valid certificates as "not yet valid". If only some users see errors, check device time first.

FAQ

How do I check when my SSL certificate expires?

Click the padlock in your browser and open the certificate details, or run openssl s_client -connect yourdomain.com:443 -servername yourdomain.com </dev/null | openssl x509 -noout -enddate. The notAfter value is the expiry date, in GMT.

What happens when an SSL certificate expires?

Browsers show a full-page "Your connection is not private" warning, and most visitors leave. APIs, webhooks and payment callbacks to your site start failing immediately. If your site uses HSTS, visitors can't click through at all.

How far in advance should I renew?

For 90-day ACME certificates, renew when about 30 days remain, which certbot's default renewal window has long done. For longer certificates, renew at two-thirds of the lifetime. Alert when fewer than 21 days remain, because by then something has already failed.

Is TLS 1.2 still OK in 2026?

Yes. TLS 1.2 with modern cipher suites is still the baseline for compatibility, and TLS 1.3 is preferred. Only TLS 1.0 and 1.1 are deprecated.

Why does my checker show a different expiry date than my host's dashboard?

You're probably looking at two different certificates: the CDN edge certificate and your origin certificate. Or the check was run without SNI and received a default certificate. Repeat the check with -servername, both against the public hostname and against the origin IP.

Will shorter certificate lifetimes affect my site?

Only if anything in your renewal is manual. Under SC-081 the maximum lifetime drops to 100 days in 2027 and 47 days in 2029. Automate issuance with ACME, then monitor the result independently.


Conclusion

Expired certificates in 2026 are rarely a forgotten payment: they're automation that stopped working without anyone noticing, so pair ACME renewal with an independent check, switch off TLS 1.0 and 1.1 at the edge, and let a scheduled BugViso audit keep re-reading the certificate for you.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.